diff --git a/CHANGELOG.md b/CHANGELOG.md index a22fed5d..45a492bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -988,6 +988,12 @@ after its public API and format compatibility policies are established. ### Fixed +- Preserve typed refusal payloads and original operational sources across + retention, migration, GC, compaction, filesystem admission, and streaming + transfer boundaries. Refusals retain exact selected-root, reader-fence, + platform, and recovery coordinates; corrupt predecessor roots retain their + decoder source. ADR-0010 records the additive diagnostic API changes. + - `ReferenceStore` reconstruction and range reads hash each selected chunk exactly once. The verification pass still runs to completion before the first output write; the emission pass now fetches each verified immutable diff --git a/ROADMAP.md b/ROADMAP.md index fbe4a7f2..4494426d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -77,7 +77,7 @@ names; use those in code, tests, and commits. ### Foundations (M1 and M2) -- [x] [F-01 Core law and fail-closed contract](#f-01-core-law-and-fail-closed-contract) — Done +- [ ] [F-01 Core law and fail-closed contract](#f-01-core-law-and-fail-closed-contract) — Partial; T-01.2 corrections await integration (#110) - [x] [F-02 BlobId exact logical identity](#f-02-blobid-exact-logical-identity) — Done - [x] [F-03 Identity layers and RepresentationId](#f-03-identity-layers-and-representationid) — Done as a model; representation codec reserved - [x] [F-04 Deterministic chunking and ChunkId](#f-04-deterministic-chunking-and-chunkid) — Done @@ -194,7 +194,8 @@ features are listed; finished prerequisites are implied. ### F-01 Core law and fail-closed contract -**Status:** Done. Governs every other feature. +**Status:** Partial. Governs every other feature; T-01.2's audit corrections +are implemented on this branch, with integration tracked in #110. For a given content identity, Keep must return exactly the bytes named by that identity, or refuse. Keep refuses, before mutating anything, a disk @@ -210,6 +211,10 @@ application policy. boundary error enum carries `expected` and `observed` fields and a preserved `source`; `unwrap_used`, `expect_used`, and `panic` are denied workspace-wide. + Typed-source audit corrections: #110; ADR-0010; + `tests/typed_refusal_source_contract.rs`, chunk-reader, selected-root, + reader-fence, predecessor, migration-stage, and GC-residue regressions. + The task remains open until the corrected implementation is integrated. - [x] T-01.3 Keep application semantics out of the core — `KEEP-STORE-016`; Echo, Git, Graft, WARP, and CLI types never enter `src/`. diff --git a/docs/adr/0010-preserve-typed-storage-refusals.md b/docs/adr/0010-preserve-typed-storage-refusals.md new file mode 100644 index 00000000..2d5f3da7 --- /dev/null +++ b/docs/adr/0010-preserve-typed-storage-refusals.md @@ -0,0 +1,67 @@ +# ADR-0010: Preserve Typed Storage Refusals + +- Status: Accepted for implementation on this branch; integration pending. +- Date: 2026-10-01 +- Owners: Keep maintainers +- Related issue: [#110](https://github.com/flyingrobots/keep/issues/110) + +## Context + +T-01.2 requires typed refusals with useful expected and observed evidence and +preserved sources. Later durable adapters weakened that foundation by +converting exact-record errors to strings, replacing failures with static +messages, or discarding a predecessor root's decoder error. An I/O port's +return type does not justify erasing its semantic payload. + +## Decision + +An adapter-owned semantic refusal implements `Error` and remains the payload +of its I/O wrapper. Closed protocol-state enums describe retention stages, +migration, and GC. Selected-root, reader-fence, filesystem-operation, and +compaction-recovery refusals retain expected and observed coordinates where +the boundary has them. These public refusal types support caller downcasts. + +`ExactRecordError::into_io` preserves original operational errors unchanged +and wraps exact-record refusals as typed `InvalidData` payloads. Retention, +migration, GC, and stage cleanup reuse this conversion. They never convert +the refusal to a message or replace a failed absence check with another error. + +Predecessor decode failures retain the exact root decoder source. Namespace +read failures retain their original OS source and operational error kind; +a missing committed namespace remains an evidenced `InvalidData` refusal. +Transfer's internal stop signal is typed while the writer retains the +original sink failure for the final transfer error. + +An `io::Error` exposes its custom payload through `get_ref`; consumers should +inspect that payload as well as `Error::source` when traversing a causal chain. +Diagnostic strings are presentation, not a stable classification API. + +## Alternatives considered + +Keeping strings preserves familiar diagnostics but prevents callers from +distinguishing the cause without parsing text. Adding a generic message +wrapper would remain a string refusal with a new name. Replacing I/O ports +with protocol-specific return types would unnecessarily change all storage +ports and external implementations. Boundary enums retain typed evidence +without changing the port signatures. + +## Consequences + +The public refusal vocabulary gains additive types and current-state +variants. Existing public variant constructors remain available. Corrupt +predecessor bytes now report `PredecessorRootRefused` with the decoder cause; +`PredecessorRootChanged` still describes a decoded selection mismatch. +Callers must match typed errors, not rely on previous diagnostic wording. + +Identity, canonical bytes, publication order, synchronization, durability, +and recovery decisions do not change. Error evidence contains coordinates +and bounds, never content bytes, keys, or unbounded physical paths. No new +dependency or performance optimization is introduced. + +Runtime regressions cover corrupted chunks, substituted retention and +migration records, malformed GC residue, fence length, selected-root bounds, +valid-but-wrong root selections, and predecessor checksum failure. Existing +fault, corruption, recovery, and public API laws remain authoritative. +`tests/typed_refusal_source_contract.rs` guards explicit textual I/O error +constructors in production source; it is a targeted source contract rather +than a general proof of all possible error flows. diff --git a/docs/adr/README.md b/docs/adr/README.md index 1348e0ca..0ebced91 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -61,3 +61,4 @@ encryption, concurrency, or public-API surface it governs. - [ADR-0007: Terminal signal process-group guard](0007-terminal-signal-process-group-guard.md) - [ADR-0008: Deadline-bounded reader retirement](0008-deadline-bounded-reader-retirement.md) - [ADR-0009: Retention roots, release, and GC liveness](0009-retention-roots-release-and-gc-liveness.md) +- [ADR-0010: Preserve typed storage refusals](0010-preserve-typed-storage-refusals.md) diff --git a/docs/audits/completed-roadmap-2026-09-30.md b/docs/audits/completed-roadmap-2026-09-30.md index 38f68861..81032b28 100644 --- a/docs/audits/completed-roadmap-2026-09-30.md +++ b/docs/audits/completed-roadmap-2026-09-30.md @@ -82,7 +82,37 @@ Linux ARM host capture encountered an unsupported CPU-model coordinate; that environment failure is not evidence that the entire Linux workspace suite passed. -## GitHub follow-up ownership +## First-milestone follow-up, 2026-10-01 + +T-01.2 / #110 now has implementation evidence on this branch for the +remaining text-only I/O refusals and source erasures discovered in the audit. +The original dated findings above describe the initial state. Integration +is pending, so the task remains unchecked. + +| Boundary | Corrective evidence | +| --- | --- | +| Chunk streaming | The consumer's I/O failure carries the original chunk verification error, including expected and observed identities. The corrupted-second-chunk law downcasts the payload. | +| Exact records and stages | Shared conversion preserves OS errors unchanged and retains typed exact-record refusals through retention, migration, GC, and cleanup. Byte-equal inode substitution and non-regular GC-record regressions downcast the original refusal. | +| Selected retained roots | Typed size bounds and selection mismatches include exact expected and observed coordinates. Sparse over-bound and valid-successor substitution laws preserve the head and reject before returning bytes. | +| Reader fences | Kind, zero-length, and inode/device disagreement have distinct typed evidence. A nonempty fence reports both observed lengths. | +| Predecessor admission | A checksum-corrupt predecessor retains its exact decoder source; namespace reads preserve their OS source and operational error kind. | +| Other filesystem operations | Initialization, namespace census, platform admission, publication prefixes, and recovery materialization use typed semantic payloads. Linux profile refusals retain observed flags and device/mount coordinates. | +| Compaction and transfer | Recovery refusals carry logical record and successor coordinates; the internal transfer stop signal is typed while original sink errors remain retained for the final result. | + +New regression assertions failed against the original chunk, retention, +migration, GC, fence, and predecessor wrappers before their fixes. Replacing +the selected-root payload with `to_string()` also makes both new root laws +fail; restoring the typed payload makes them pass. The production source +contract prevents direct literal/formatted I/O payloads and the identified +refusal-stringification patterns. It does not prove arbitrary error flows. + +ADR-0010 records the diagnostic API decision. No format, identity, write +ordering, synchronization, or recovery protocol is changed. The existing +debug/release workspace, Linux ext4 storage, and killed-writer matrix checks +passed; selected-root checks were rerun after separating reading from +selection admission to keep the changed functions within the size limit. + +## GitHub ownership index Tracking container: [completed-roadmap audit follow-ups](https://github.com/flyingrobots/keep/issues/132). It coordinates work and integration gates; it is not another executable PR. diff --git a/src/adapters/compaction/mod.rs b/src/adapters/compaction/mod.rs index 0b357c11..23f9c647 100644 --- a/src/adapters/compaction/mod.rs +++ b/src/adapters/compaction/mod.rs @@ -19,6 +19,7 @@ mod interruption_tests; mod observation; mod plan; mod recovery; +mod recovery_refusal; #[cfg(test)] mod test_fixture; @@ -30,3 +31,4 @@ pub use plan::{CompactionPlan, CompactionRefusal, CompactionSegmentDisposition, pub(in crate::adapters) use recovery::recover_compaction_unchecked_for_tests; pub use recovery::{CompactionRecovery, FilesystemCompactionRecoveryError, recover_compaction}; pub(in crate::adapters) use recovery::{CompleteStageEvidence, recover_with}; +pub use recovery_refusal::CompactionRecoveryRefusal; diff --git a/src/adapters/compaction/recovery.rs b/src/adapters/compaction/recovery.rs index 3879d0e1..67cd3ec6 100644 --- a/src/adapters/compaction/recovery.rs +++ b/src/adapters/compaction/recovery.rs @@ -312,7 +312,9 @@ fn require_derivable_segment( let named = snapshot.record(record.identity()).ok_or_else(|| { refused( "derivable segment", - io::Error::other("a staged record is not named"), + super::CompactionRecoveryRefusal::RecordNotNamed { + identity: record.identity(), + }, ) })?; if named.header() != record.header() @@ -321,7 +323,9 @@ fn require_derivable_segment( { return Err(refused( "derivable segment", - io::Error::other("a staged record differs from the named record"), + super::CompactionRecoveryRefusal::RecordMismatch { + identity: record.identity(), + }, )); } } @@ -346,7 +350,9 @@ fn require_unpublished_segment( if snapshot.record(record.identity()).is_some() { return Err(refused( "unpublished segment", - io::Error::other("a staged record is already named"), + super::CompactionRecoveryRefusal::RecordAlreadyNamed { + identity: record.identity(), + }, )); } } @@ -375,7 +381,12 @@ fn require_successor_candidate( } else { Err(refused( "successor candidate", - io::Error::other("the staged catalog is not the current head's successor"), + super::CompactionRecoveryRefusal::SuccessorMismatch { + expected_generation: successor, + observed_generation: catalog.generation(), + expected_predecessor: current.catalog_digest(), + observed_predecessor: catalog.previous_catalog_digest(), + }, )) } } @@ -390,12 +401,16 @@ fn discard_derivable( let staging = discarder .inventory .parent_directory(RecoveryStageParent::Staging); - let observed = read_stage(discarder, RecoveryStageParent::Staging, name)? - .ok_or_else(|| refused("discard stage", io::Error::other("the stage vanished")))?; + let observed = read_stage(discarder, RecoveryStageParent::Staging, name)?.ok_or_else(|| { + refused( + "discard stage", + super::CompactionRecoveryRefusal::StageAbsent, + ) + })?; if observed != expected { return Err(refused( "discard stage", - io::Error::other("the stage changed after assessment"), + super::CompactionRecoveryRefusal::StageChanged, )); } staging diff --git a/src/adapters/compaction/recovery_refusal.rs b/src/adapters/compaction/recovery_refusal.rs new file mode 100644 index 00000000..b3655ce2 --- /dev/null +++ b/src/adapters/compaction/recovery_refusal.rs @@ -0,0 +1,50 @@ +//! This module owns semantic refusals while classifying compaction residue. + +use std::error::Error; +use std::fmt; + +use crate::{CatalogDigest, CatalogGeneration, SegmentRecordIdentity}; + +/// Why compaction residue cannot be proved safe to discard or finalize. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum CompactionRecoveryRefusal { + /// The current catalog does not name a record needed to reproduce the stage. + RecordNotNamed { + /// The staged logical record identity. + identity: SegmentRecordIdentity, + }, + /// The catalog-selected record differs from the staged record. + RecordMismatch { + /// The identity under which the differing records were found. + identity: SegmentRecordIdentity, + }, + /// An allegedly unpublished stage holds a record already named by the catalog. + RecordAlreadyNamed { + /// The already-published logical record identity. + identity: SegmentRecordIdentity, + }, + /// A staged catalog does not bind the current head as its exact predecessor. + SuccessorMismatch { + /// The required successor generation. + expected_generation: CatalogGeneration, + /// The stage's generation. + observed_generation: CatalogGeneration, + /// The required predecessor digest. + expected_predecessor: CatalogDigest, + /// The stage's predecessor, absent only for an initial catalog. + observed_predecessor: Option, + }, + /// The assessed stage is no longer present. + StageAbsent, + /// The stage's current bytes differ from the assessed bytes. + StageChanged, +} + +impl fmt::Display for CompactionRecoveryRefusal { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "compaction recovery evidence refused: {self:?}") + } +} + +impl Error for CompactionRecoveryRefusal {} diff --git a/src/adapters/durable/snapshot.rs b/src/adapters/durable/snapshot.rs index 82d549a2..1c929c91 100644 --- a/src/adapters/durable/snapshot.rs +++ b/src/adapters/durable/snapshot.rs @@ -230,7 +230,14 @@ fn anchors( let bytes = view .retained_root(namespace) .map_err(|source| refused(io::Error::other(source)))? - .ok_or_else(|| refused(io::Error::other("the selected root is absent")))?; + .ok_or_else(|| { + refused(io::Error::other( + crate::RetentionSnapshotRefusal::SelectedRootAbsent { + expected_generation: entry.root_generation(), + expected_digest: entry.root_digest(), + }, + )) + })?; let root = AdmittedRetentionRoot::decode(&bytes) .map_err(|source| refused(io::Error::new(io::ErrorKind::InvalidData, source)))?; for anchor in root.root().anchors() { diff --git a/src/adapters/exports.rs b/src/adapters/exports.rs index e9af8313..9985523e 100644 --- a/src/adapters/exports.rs +++ b/src/adapters/exports.rs @@ -41,6 +41,7 @@ pub use super::durable::*; pub use super::filesystem_catalog_publication_error::FilesystemCatalogPublicationError; pub use super::filesystem_catalog_publisher::FilesystemCatalogPublisher; pub use super::filesystem_catalog_snapshot::FilesystemCatalogSnapshot; +pub use super::filesystem_operation_refusal::FilesystemOperationRefusal; pub use super::filesystem_platform_admission::FilesystemPlatformAdmission; pub use super::filesystem_platform_admission_error::FilesystemPlatformAdmissionError; pub use super::filesystem_recovery_inventory_reader::FilesystemRecoveryInventoryReader; diff --git a/src/adapters/filesystem_catalog_catalog.rs b/src/adapters/filesystem_catalog_catalog.rs index 4539e908..443113a8 100644 --- a/src/adapters/filesystem_catalog_catalog.rs +++ b/src/adapters/filesystem_catalog_catalog.rs @@ -33,7 +33,14 @@ pub(super) fn write_prefix( prefix: usize, ) -> io::Result<()> { let bytes = catalog.encoded().get(..prefix).ok_or_else(|| { - io::Error::new(io::ErrorKind::InvalidInput, "catalog prefix exceeds bytes") + io::Error::new( + io::ErrorKind::InvalidInput, + super::FilesystemOperationRefusal::PrefixBound { + artifact: CatalogRestartArtifact::Catalog, + maximum: catalog.encoded().len(), + observed: prefix, + }, + ) })?; write_bytes(publisher, bytes) } diff --git a/src/adapters/filesystem_catalog_head.rs b/src/adapters/filesystem_catalog_head.rs index 27df44f0..c8e307c3 100644 --- a/src/adapters/filesystem_catalog_head.rs +++ b/src/adapters/filesystem_catalog_head.rs @@ -32,10 +32,16 @@ pub(super) fn write_prefix( head: &CanonicalPublicationHead, prefix: usize, ) -> io::Result<()> { - let bytes = head - .encoded() - .get(..prefix) - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "head prefix exceeds bytes"))?; + let bytes = head.encoded().get(..prefix).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + super::FilesystemOperationRefusal::PrefixBound { + artifact: CatalogRestartArtifact::Head, + maximum: head.encoded().len(), + observed: prefix, + }, + ) + })?; write_bytes(publisher, bytes) } diff --git a/src/adapters/filesystem_exact_record.rs b/src/adapters/filesystem_exact_record.rs index 35ae83f5..f04c8597 100644 --- a/src/adapters/filesystem_exact_record.rs +++ b/src/adapters/filesystem_exact_record.rs @@ -5,7 +5,7 @@ //! FIFO or device planted at a protocol name refuses by kind instead of //! hanging under the writer lock. Every read is bounded by the caller's exact //! expected length and refuses trailing bytes. Callers map each -//! [`ExactRecordRefusal`] to their own typed refusal or message, so the +//! [`ExactRecordRefusal`] to their own typed refusal or retain it as a source, so the //! primitives carry no protocol vocabulary of their own. use std::error::Error; @@ -23,6 +23,11 @@ pub(super) struct EntryIdentity { } impl EntryIdentity { + /// The exact device and inode coordinates recorded at admission. + pub(super) const fn coordinates(self) -> (u64, u64) { + (self.device, self.inode) + } + /// Reads the identity behind an open file handle. pub(super) fn of_file(file: &File) -> io::Result { file.metadata().map(|metadata| Self::from(&metadata)) @@ -71,6 +76,16 @@ pub(super) enum ExactRecordError { Refused(ExactRecordRefusal), } +impl ExactRecordError { + /// Keeps operational failures unchanged and semantic refusals downcastable. + pub(super) fn into_io(self) -> io::Error { + match self { + Self::Io(source) => source, + refusal @ Self::Refused(_) => io::Error::new(io::ErrorKind::InvalidData, refusal), + } + } +} + impl fmt::Display for ExactRecordRefusal { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(match self { diff --git a/src/adapters/filesystem_initialization_namespace.rs b/src/adapters/filesystem_initialization_namespace.rs index f3d1a903..fea798d7 100644 --- a/src/adapters/filesystem_initialization_namespace.rs +++ b/src/adapters/filesystem_initialization_namespace.rs @@ -235,7 +235,7 @@ fn is_canonical(name: &OsStr, canonical_names: &[&str]) -> bool { fn ambiguous_namespace() -> io::Error { io::Error::new( io::ErrorKind::InvalidData, - "store root is not an empty or partial canonical initialization namespace", + super::FilesystemOperationRefusal::InitializationNamespace, ) } diff --git a/src/adapters/filesystem_initialization_storage.rs b/src/adapters/filesystem_initialization_storage.rs index 833b47e1..b0fc2b45 100644 --- a/src/adapters/filesystem_initialization_storage.rs +++ b/src/adapters/filesystem_initialization_storage.rs @@ -51,14 +51,14 @@ impl FilesystemInitializationStorage { pub(super) fn into_lock(self) -> io::Result { self.lock.ok_or_else(|| { - io::Error::other("initialization completed without retained writer authority") + io::Error::other(super::FilesystemOperationRefusal::WriterAuthorityAbsent) }) } fn admit_directory(&self, name: &str) -> io::Result<()> { if self.lock.is_none() { return Err(io::Error::other( - "initialization directory mutation requires writer authority", + super::FilesystemOperationRefusal::WriterAuthorityAbsent, )); } match self.directory.create_dir(name) { @@ -79,7 +79,9 @@ impl StoreInitializationStorage for FilesystemInitializationStorage { fn open_and_lock_writer_file(&mut self) -> io::Result<()> { if self.lock.is_some() { - return Err(io::Error::other("writer authority was already acquired")); + return Err(io::Error::other( + super::FilesystemOperationRefusal::WriterAlreadyAcquired, + )); } let lock = FilesystemWriterLock::initialize_in(self.directory.try_clone()?) .map_err(io::Error::other)?; @@ -102,7 +104,7 @@ impl StoreInitializationStorage for FilesystemInitializationStorage { fn synchronize_root(&mut self) -> io::Result<()> { if self.lock.is_none() { return Err(io::Error::other( - "root synchronization requires writer authority", + super::FilesystemOperationRefusal::WriterAuthorityAbsent, )); } sync_capable_directory::open(&self.directory, ".")? diff --git a/src/adapters/filesystem_operation_refusal.rs b/src/adapters/filesystem_operation_refusal.rs new file mode 100644 index 00000000..ba7ae57a --- /dev/null +++ b/src/adapters/filesystem_operation_refusal.rs @@ -0,0 +1,115 @@ +//! This module owns semantic filesystem operation and platform-admission refusals. + +use std::error::Error; +use std::fmt; + +/// A filesystem operation's semantic refusal, distinct from its original OS errors. +#[derive(Debug)] +#[non_exhaustive] +pub enum FilesystemOperationRefusal { + /// The root is not an admitted canonical initialization namespace. + InitializationNamespace, + /// The operation requires retained writer authority. + WriterAuthorityAbsent, + /// Writer authority has already been acquired. + WriterAlreadyAcquired, + /// A synchronization target is not a directory. + DirectoryRequired, + /// A named entry no longer identifies its pinned handle. + IdentityChanged { + /// The pinned device and inode. + expected: (u64, u64), + /// The current entry's device and inode. + observed: (u64, u64), + }, + /// A requested recovery inventory count exceeds the protocol ceiling. + InventoryLimit { + /// The protocol ceiling. + maximum: u64, + /// The requested count. + observed: u64, + }, + /// Recovery entry-count arithmetic overflowed. + InventoryCountOverflow { + /// The count before incrementing. + observed: u64, + }, + /// A recovery count exceeds the host address space. + InventoryAddressSpace { + /// The requested count. + observed: u64, + /// The original checked conversion failure. + source: std::num::TryFromIntError, + }, + /// Recovery name capacity cannot include the drift witness. + InventoryCapacityOverflow { + /// The requested name count. + observed: usize, + }, + /// The platform cannot supply the required raw Unix entry names. + RawNamesUnsupported, + /// A requested publication prefix exceeds the encoded record. + PrefixBound { + /// The record being staged. + artifact: super::CatalogRestartArtifact, + /// The encoded length. + maximum: usize, + /// The requested prefix length. + observed: usize, + }, + /// A recovery-stage read ended before its admitted boundary. + IncompleteRead { + /// The admitted byte length. + expected: u64, + /// The byte count actually read. + observed: u64, + }, + /// The operation requires the admitted Linux ext4 profile. + LinuxProfileRequired, + /// An internal protocol directory name has no component. + EmptyProtocolName, + /// The kernel omitted required platform identity observations. + PlatformStatus { + /// The required statx mask. + expected: u32, + /// The returned statx mask. + observed: u32, + }, + /// The filesystem does not satisfy the writable case-sensitive ext4 profile. + FilesystemProfile { + /// The required ext4 filesystem type. + expected_filesystem: i128, + /// The observed filesystem type. + observed_filesystem: i128, + /// The observed mount flags. + observed_mount_flags: u64, + /// The observed inode flags. + observed_inode_flags: u32, + }, + /// A protocol directory crosses the admitted device or mount. + MountBoundaryChanged { + /// The root's device major and minor. + expected_device: (u32, u32), + /// The child's device major and minor. + observed_device: (u32, u32), + /// The root's mount identity. + expected_mount: u64, + /// The child's mount identity. + observed_mount: u64, + }, +} + +impl fmt::Display for FilesystemOperationRefusal { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "filesystem operation refused: {self:?}") + } +} + +impl Error for FilesystemOperationRefusal { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::InventoryAddressSpace { source, .. } => Some(source), + _ => None, + } + } +} diff --git a/src/adapters/filesystem_platform_profile.rs b/src/adapters/filesystem_platform_profile.rs index aea9e2c9..8d2382f7 100644 --- a/src/adapters/filesystem_platform_profile.rs +++ b/src/adapters/filesystem_platform_profile.rs @@ -83,7 +83,7 @@ pub(super) fn open_version_two(store_root: &Path) -> io::Result { pub(super) fn open(_store_root: &Path) -> io::Result { Err(io::Error::new( io::ErrorKind::Unsupported, - "filesystem initialization currently requires the admitted Linux ext4 profile", + super::FilesystemOperationRefusal::LinuxProfileRequired, )) } @@ -91,7 +91,7 @@ pub(super) fn open(_store_root: &Path) -> io::Result { pub(super) fn open_version_two(_store_root: &Path) -> io::Result { Err(io::Error::new( io::ErrorKind::Unsupported, - "version-two reopen currently requires the admitted Linux ext4 profile", + super::FilesystemOperationRefusal::LinuxProfileRequired, )) } @@ -116,9 +116,12 @@ fn admit_linux_profile(directory: &Dir, protocol_directories: &[&str]) -> io::Re #[cfg(target_os = "linux")] fn open_protocol_directory(root: &Dir, name: &str) -> io::Result { let mut components = name.split('/'); - let first = components - .next() - .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "empty protocol name"))?; + let first = components.next().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + super::FilesystemOperationRefusal::EmptyProtocolName, + ) + })?; let mut current = super::sync_capable_directory::open(root, first)?; for component in components { current = super::sync_capable_directory::open(¤t, component)?; @@ -137,7 +140,12 @@ fn linux_directory_properties(file: &std::fs::File) -> io::Result io::Result io::Result { Err(io::Error::new( io::ErrorKind::Unsupported, - "filesystem root identity currently requires the admitted Linux ext4 profile", + super::FilesystemOperationRefusal::LinuxProfileRequired, )) } @@ -274,7 +291,14 @@ fn admit_linux_properties( || mount_flags.contains(rustix::fs::StatVfsMountFlags::RDONLY) || inode_flags & EXT4_CASEFOLD_FLAG != 0 { - return Err(unsupported_linux_profile()); + return Err(unsupported_linux_profile( + super::FilesystemOperationRefusal::FilesystemProfile { + expected_filesystem: i128::from(EXT4_SUPER_MAGIC), + observed_filesystem: i128::from(filesystem_type), + observed_mount_flags: mount_flags.bits(), + observed_inode_flags: inode_flags, + }, + )); } Ok(()) } @@ -289,17 +313,21 @@ fn admit_linux_child_properties( || root.device_minor != child.device_minor || root.mount_id != child.mount_id { - return Err(unsupported_linux_profile()); + return Err(unsupported_linux_profile( + super::FilesystemOperationRefusal::MountBoundaryChanged { + expected_device: (root.device_major, root.device_minor), + observed_device: (child.device_major, child.device_minor), + expected_mount: root.mount_id, + observed_mount: child.mount_id, + }, + )); } Ok(()) } #[cfg(target_os = "linux")] -fn unsupported_linux_profile() -> io::Error { - io::Error::new( - io::ErrorKind::Unsupported, - "store namespace does not satisfy one local writable case-sensitive ext4 profile", - ) +fn unsupported_linux_profile(refusal: super::FilesystemOperationRefusal) -> io::Error { + io::Error::new(io::ErrorKind::Unsupported, refusal) } #[cfg(all(test, target_os = "linux"))] diff --git a/src/adapters/filesystem_platform_profile_tests.rs b/src/adapters/filesystem_platform_profile_tests.rs index 13f97f36..aae43e9b 100644 --- a/src/adapters/filesystem_platform_profile_tests.rs +++ b/src/adapters/filesystem_platform_profile_tests.rs @@ -61,8 +61,9 @@ fn assert_unsupported(result: &std::io::Result<()>) { result, Err(error) if error.kind() == std::io::ErrorKind::Unsupported - && error.to_string() - == "store namespace does not satisfy one local writable case-sensitive ext4 profile" + && matches!(error.get_ref().and_then(|source| source.downcast_ref::()), + Some(crate::FilesystemOperationRefusal::FilesystemProfile { .. } + | crate::FilesystemOperationRefusal::MountBoundaryChanged { .. })) )); } diff --git a/src/adapters/filesystem_recovery_inventory_scan.rs b/src/adapters/filesystem_recovery_inventory_scan.rs index 7077be77..66bbc2d4 100644 --- a/src/adapters/filesystem_recovery_inventory_scan.rs +++ b/src/adapters/filesystem_recovery_inventory_scan.rs @@ -9,18 +9,23 @@ use super::{RecoveryEntryName, RecoveryInventoryLimit}; pub(super) fn count_entries(directory: &Dir, remaining: u64) -> io::Result { if remaining > RecoveryInventoryLimit::PROTOCOL_MAXIMUM { return Err(invalid_input( - "recovery count budget exceeds protocol maximum", + super::FilesystemOperationRefusal::InventoryLimit { + maximum: RecoveryInventoryLimit::PROTOCOL_MAXIMUM, + observed: remaining, + }, )); } - let ceiling = remaining - .checked_add(1) - .ok_or_else(|| invalid_input("recovery count budget cannot admit a drift witness"))?; + let ceiling = remaining.checked_add(1).ok_or_else(|| { + invalid_input(super::FilesystemOperationRefusal::InventoryCountOverflow { + observed: remaining, + }) + })?; let mut observed = 0_u64; for entry in directory.entries()? { let _entry = entry?; - observed = observed - .checked_add(1) - .ok_or_else(|| invalid_input("recovery entry count overflowed"))?; + observed = observed.checked_add(1).ok_or_else(|| { + invalid_input(super::FilesystemOperationRefusal::InventoryCountOverflow { observed }) + })?; if observed == ceiling { break; } @@ -34,14 +39,23 @@ pub(super) fn read_entry_names( ) -> io::Result> { if expected_count > RecoveryInventoryLimit::PROTOCOL_MAXIMUM { return Err(invalid_input( - "recovery expected count exceeds protocol maximum", + super::FilesystemOperationRefusal::InventoryLimit { + maximum: RecoveryInventoryLimit::PROTOCOL_MAXIMUM, + observed: expected_count, + }, )); } - let expected = usize::try_from(expected_count) - .map_err(|_| invalid_input("recovery expected count does not fit the address space"))?; - let capacity = expected - .checked_add(1) - .ok_or_else(|| invalid_input("recovery name capacity overflowed"))?; + let expected = usize::try_from(expected_count).map_err(|source| { + invalid_input(super::FilesystemOperationRefusal::InventoryAddressSpace { + observed: expected_count, + source, + }) + })?; + let capacity = expected.checked_add(1).ok_or_else(|| { + invalid_input( + super::FilesystemOperationRefusal::InventoryCapacityOverflow { observed: expected }, + ) + })?; let mut names = Vec::with_capacity(capacity); for entry in directory.entries()? { names.push(entry_name(&entry?)?); @@ -64,10 +78,10 @@ fn entry_name(entry: &cap_std::fs::DirEntry) -> io::Result { fn entry_name(_entry: &cap_std::fs::DirEntry) -> io::Result { Err(io::Error::new( io::ErrorKind::Unsupported, - "raw recovery entry names currently require a Unix platform", + super::FilesystemOperationRefusal::RawNamesUnsupported, )) } -fn invalid_input(message: &'static str) -> io::Error { - io::Error::new(io::ErrorKind::InvalidInput, message) +fn invalid_input(refusal: super::FilesystemOperationRefusal) -> io::Error { + io::Error::new(io::ErrorKind::InvalidInput, refusal) } diff --git a/src/adapters/filesystem_recovery_namespace.rs b/src/adapters/filesystem_recovery_namespace.rs index 6ced5dcc..cd2ca544 100644 --- a/src/adapters/filesystem_recovery_namespace.rs +++ b/src/adapters/filesystem_recovery_namespace.rs @@ -54,7 +54,10 @@ impl PinnedRecoveryDirectory { RecoveryInventoryOperation::VerifyNamespace, io::Error::new( io::ErrorKind::InvalidData, - "recovery namespace changed identity after it was pinned", + super::FilesystemOperationRefusal::IdentityChanged { + expected: self.identity.coordinates(), + observed: observed.coordinates(), + }, ), )) } diff --git a/src/adapters/filesystem_recovery_stage_materialization.rs b/src/adapters/filesystem_recovery_stage_materialization.rs index 3c25e7b8..43df7273 100644 --- a/src/adapters/filesystem_recovery_stage_materialization.rs +++ b/src/adapters/filesystem_recovery_stage_materialization.rs @@ -72,7 +72,7 @@ fn read_exact( expected: length, source: io::Error::new( io::ErrorKind::UnexpectedEof, - "recovery stage ended before the expected boundary", + super::FilesystemOperationRefusal::IncompleteRead { expected, observed }, ), }); } diff --git a/src/adapters/filesystem_writer_lock.rs b/src/adapters/filesystem_writer_lock.rs index c3a98447..42c10437 100644 --- a/src/adapters/filesystem_writer_lock.rs +++ b/src/adapters/filesystem_writer_lock.rs @@ -174,7 +174,10 @@ fn verify_current_identity( WriterLockAcquirePhase::VerifyFileIdentity, io::Error::new( io::ErrorKind::InvalidData, - "writer.lock changed identity during acquisition", + super::FilesystemOperationRefusal::IdentityChanged { + expected: (expected.device, expected.inode), + observed: (observed.device, observed.inode), + }, ), )) } diff --git a/src/adapters/gc/filesystem_gc_authority.rs b/src/adapters/gc/filesystem_gc_authority.rs index 9c1b7f5c..995e8ab5 100644 --- a/src/adapters/gc/filesystem_gc_authority.rs +++ b/src/adapters/gc/filesystem_gc_authority.rs @@ -157,9 +157,9 @@ impl FilesystemGcAuthority { } else { residue.intent.as_deref() }; - let admitted = AdmittedGcRetirementIntent::decode( - bytes.ok_or_else(|| observe(residue::invalid("GC intent vanished")))?, - ) + let admitted = AdmittedGcRetirementIntent::decode(bytes.ok_or_else(|| { + observe(residue::invalid(super::FilesystemGcRefusal::IntentAbsent)) + })?) .map_err(|source| observe(residue::invalid_from(source)))?; let intent = CanonicalGcRetirementIntent::from_intent(admitted.intent()) .map_err(Error::Encode)?; @@ -291,7 +291,8 @@ impl FilesystemGcAuthority { } fn prior_generation(receipt: Option<&[u8]>) -> Result { - let bytes = receipt.ok_or_else(|| observe(residue::invalid("GC receipt vanished")))?; + let bytes = receipt + .ok_or_else(|| observe(residue::invalid(super::FilesystemGcRefusal::ReceiptAbsent)))?; AdmittedGcRetirementReceipt::decode_unbound(bytes) .map(|receipt| receipt.generation()) .map_err(|source| observe(residue::invalid_from(source))) diff --git a/src/adapters/gc/filesystem_gc_refusal.rs b/src/adapters/gc/filesystem_gc_refusal.rs new file mode 100644 index 00000000..1e160e0d --- /dev/null +++ b/src/adapters/gc/filesystem_gc_refusal.rs @@ -0,0 +1,43 @@ +//! This module owns gc filesystem protocol-state refusals. + +use std::error::Error; +use std::fmt; + +/// A semantic filesystem protocol refusal, preserved through I/O adapters. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum FilesystemGcRefusal { + /// GC intent vanished. + IntentAbsent, + /// GC receipt vanished. + ReceiptAbsent, + /// No GC retirement is in progress. + NoRetirement, + /// A GC candidate is still present. + CandidateStillPresent, + /// GC candidate index out of range. + CandidateIndex, + /// GC candidate kind or length disagrees with the intent. + CandidateKindOrLength, + /// GC receipt is absent before intent removal. + ReceiptAbsentBeforeIntentRemoval, + /// GC intent bound overflow. + IntentBoundOverflow, +} + +impl fmt::Display for FilesystemGcRefusal { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::IntentAbsent => "GC intent vanished", + Self::ReceiptAbsent => "GC receipt vanished", + Self::NoRetirement => "no GC retirement is in progress", + Self::CandidateStillPresent => "a GC candidate is still present", + Self::CandidateIndex => "GC candidate index out of range", + Self::CandidateKindOrLength => "GC candidate kind or length disagrees with the intent", + Self::ReceiptAbsentBeforeIntentRemoval => "GC receipt is absent before intent removal", + Self::IntentBoundOverflow => "GC intent bound overflow", + }) + } +} + +impl Error for FilesystemGcRefusal {} diff --git a/src/adapters/gc/filesystem_gc_residue.rs b/src/adapters/gc/filesystem_gc_residue.rs index 0bda44a9..6c9dde88 100644 --- a/src/adapters/gc/filesystem_gc_residue.rs +++ b/src/adapters/gc/filesystem_gc_residue.rs @@ -29,7 +29,7 @@ pub(super) fn read(gc: &Dir, segments: &Dir) -> io::Result { let intent_bound = super::intent_format::canonical_length(GcRetirementIntent::MAXIMUM_CANDIDATE_COUNT) .and_then(|length| length.checked_add(1)) - .ok_or_else(|| invalid("GC intent bound overflow"))?; + .ok_or_else(|| invalid(super::FilesystemGcRefusal::IntentBoundOverflow))?; let receipt_bound = RECEIPT_LENGTH.saturating_add(1); let intent = read_bounded(gc, INTENT, intent_bound)?; let candidates_present = match intent.as_deref().map(AdmittedGcRetirementIntent::decode) { @@ -64,25 +64,51 @@ fn read_bounded(gc: &Dir, name: &str, bound: usize) -> io::Result Ok(bytes.map(Vec::into_boxed_slice)), Err(ExactRecordError::Io(source)) => Err(source), - Err(ExactRecordError::Refused(refusal)) => Err(io::Error::new( - io::ErrorKind::InvalidData, - refusal.to_string(), - )), + Err(error @ ExactRecordError::Refused(_)) => Err(error.into_io()), } } /// Removes a discardable stage and proves it gone. pub(super) fn discard(gc: &Dir, name: &str) -> io::Result<()> { gc.remove_file(name)?; - exact_record::require_absent(gc, name) - .map_err(|_source| invalid("discarded GC stage remained visible"))?; + exact_record::require_absent(gc, name).map_err(ExactRecordError::into_io)?; crate::adapters::filesystem_catalog_artifact::synchronize_directory(gc) } -pub(super) fn invalid(message: &'static str) -> io::Error { - io::Error::new(io::ErrorKind::InvalidData, message) +pub(super) fn invalid(refusal: super::FilesystemGcRefusal) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, refusal) } pub(super) fn invalid_from(error: impl std::error::Error + Send + Sync + 'static) -> io::Error { io::Error::new(io::ErrorKind::InvalidData, error) } +#[cfg(test)] +mod tests { + use std::error::Error; + use std::fs; + + use super::{ExactRecordError, INTENT, read_bounded}; + use crate::adapters::filesystem_exact_record::ExactRecordRefusal; + use crate::adapters::filesystem_test_sandbox::TestDirectory; + + #[test] + fn a_non_regular_gc_record_retains_its_exact_refusal() -> Result<(), Box> { + let sandbox = TestDirectory::create("gc-residue-typed-kind")?; + fs::create_dir(sandbox.path().join(INTENT))?; + let directory = + cap_std::fs::Dir::open_ambient_dir(sandbox.path(), cap_std::ambient_authority())?; + let error = read_bounded(&directory, INTENT, 1) + .err() + .ok_or("non-regular GC intent admitted")?; + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + assert!(matches!( + error + .get_ref() + .and_then(|source| source.downcast_ref::()), + Some(ExactRecordError::Refused(ExactRecordRefusal::KindOrLength)) + )); + drop(directory); + sandbox.remove()?; + Ok(()) + } +} diff --git a/src/adapters/gc/filesystem_gc_storage.rs b/src/adapters/gc/filesystem_gc_storage.rs index 7f67fa5b..8d5ddde2 100644 --- a/src/adapters/gc/filesystem_gc_storage.rs +++ b/src/adapters/gc/filesystem_gc_storage.rs @@ -15,7 +15,7 @@ use crate::adapters::physical_pool_name; use crate::adapters::retention::FilesystemRetentionStage; fn no_retirement() -> io::Error { - invalid("no GC retirement is in progress") + invalid(super::FilesystemGcRefusal::NoRetirement) } impl FilesystemGcAuthority { @@ -46,7 +46,9 @@ impl FilesystemGcAuthority { match self.segments.symlink_metadata(&name) { Err(source) if source.kind() == io::ErrorKind::NotFound => {} Err(source) => return Err(source), - Ok(_present) => return Err(invalid("a GC candidate is still present")), + Ok(_present) => { + return Err(invalid(super::FilesystemGcRefusal::CandidateStillPresent)); + } } } let inventory = segment_pool_inventory::read( @@ -144,13 +146,11 @@ impl GcExecutionStorage for FilesystemGcAuthority { .candidates() .get(index) .copied() - .ok_or_else(|| invalid("GC candidate index out of range"))?; + .ok_or_else(|| invalid(super::FilesystemGcRefusal::CandidateIndex))?; let name = physical_pool_name::segment(candidate.segment_digest()); let metadata = self.segments.symlink_metadata(&name)?; if !metadata.is_file() || metadata.len() != candidate.segment_length() { - return Err(invalid( - "GC candidate kind or length disagrees with the intent", - )); + return Err(invalid(super::FilesystemGcRefusal::CandidateKindOrLength)); } segment_pool_inventory::admit_entry( &self.segments, @@ -161,8 +161,7 @@ impl GcExecutionStorage for FilesystemGcAuthority { ) .map_err(|source| io::Error::new(io::ErrorKind::InvalidData, source))?; self.segments.remove_file(&name)?; - exact_record::require_absent(&self.segments, &name) - .map_err(|_source| invalid("unlinked GC candidate remained visible")) + exact_record::require_absent(&self.segments, &name).map_err(ExactRecordError::into_io) } fn synchronize_segment_pool(&mut self, _index: usize) -> io::Result<()> { @@ -205,14 +204,15 @@ impl GcExecutionStorage for FilesystemGcAuthority { let context = self.context.as_ref().ok_or_else(no_retirement)?; let bytes = match exact_record::read_exact_optional(&self.gc, RECEIPT, 320) { Ok(Some(bytes)) => bytes, - Ok(None) => return Err(invalid("GC receipt is absent before intent removal")), - Err(ExactRecordError::Io(source)) => return Err(source), - Err(ExactRecordError::Refused(refusal)) => { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - refusal.to_string(), + Ok(None) => { + return Err(invalid( + super::FilesystemGcRefusal::ReceiptAbsentBeforeIntentRemoval, )); } + Err(ExactRecordError::Io(source)) => return Err(source), + Err(error @ ExactRecordError::Refused(_)) => { + return Err(error.into_io()); + } }; let intent = AdmittedGcRetirementIntent::decode(context.intent.encoded()) .map_err(|source| io::Error::new(io::ErrorKind::InvalidData, source))?; @@ -226,8 +226,7 @@ impl GcExecutionStorage for FilesystemGcAuthority { self.context()?.receipt = Some(receipt); } self.gc.remove_file(INTENT)?; - exact_record::require_absent(&self.gc, INTENT) - .map_err(|_source| invalid("removed GC intent remained visible")) + exact_record::require_absent(&self.gc, INTENT).map_err(ExactRecordError::into_io) } fn synchronize_gc_after_intent_removal(&mut self) -> io::Result<()> { diff --git a/src/adapters/gc/mod.rs b/src/adapters/gc/mod.rs index b8cb76b0..5b668703 100644 --- a/src/adapters/gc/mod.rs +++ b/src/adapters/gc/mod.rs @@ -26,6 +26,7 @@ mod execution_phase; mod execution_storage; mod filesystem_gc_authority; mod filesystem_gc_error; +mod filesystem_gc_refusal; mod filesystem_gc_residue; mod filesystem_gc_storage; #[cfg(test)] @@ -94,6 +95,7 @@ pub use execution_phase::{GcExecutionPhase, GcExecutionPoint}; pub use execution_storage::GcExecutionStorage; pub use filesystem_gc_authority::{FilesystemGcAuthority, GcRecoveryReport, PreparedGcExecution}; pub use filesystem_gc_error::FilesystemGcError; +pub use filesystem_gc_refusal::FilesystemGcRefusal; pub(in crate::adapters) use filesystem_gc_residue::GC_ENTRY_NAMES; pub use intent::GcRetirementIntent; pub use intent_coordinates::GcRetirementIntentCoordinates; diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs index a80b69a2..6f8a148e 100644 --- a/src/adapters/mod.rs +++ b/src/adapters/mod.rs @@ -83,6 +83,7 @@ mod filesystem_catalog_storage; mod filesystem_exact_record; mod filesystem_initialization_namespace; mod filesystem_initialization_storage; +mod filesystem_operation_refusal; mod filesystem_platform_admission; mod filesystem_platform_admission_error; mod filesystem_platform_profile; diff --git a/src/adapters/pipeline/transfer.rs b/src/adapters/pipeline/transfer.rs index 0a2a8b06..e2e776a2 100644 --- a/src/adapters/pipeline/transfer.rs +++ b/src/adapters/pipeline/transfer.rs @@ -185,6 +185,21 @@ enum Failure { Accounting, } +#[derive(Clone, Copy, Debug)] +enum TransferStop { + Sink, + Cancelled, + Accounting, +} + +impl std::fmt::Display for TransferStop { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "transfer stopped: {self:?}") + } +} + +impl std::error::Error for TransferStop {} + /// The writer the read cores emit into. Each `write` is one verified slice /// of an immutable chunk, handed to the sink as a segment without a copy. struct WindowedWriter<'sink, K: TransferSink + ?Sized, C: ?Sized> { @@ -211,8 +226,13 @@ where } fn fail(&mut self, failure: Failure) -> io::Error { + let reason = match &failure { + Failure::Sink(_) => TransferStop::Sink, + Failure::Cancelled => TransferStop::Cancelled, + Failure::Accounting => TransferStop::Accounting, + }; self.failure = Some(failure); - io::Error::other("the transfer stopped") + io::Error::other(reason) } fn apply(&mut self, bytes: &[u8]) -> io::Result<()> { diff --git a/src/adapters/retention.rs b/src/adapters/retention.rs index a052e2e3..d3efdc46 100644 --- a/src/adapters/retention.rs +++ b/src/adapters/retention.rs @@ -59,6 +59,7 @@ mod filesystem_retention_snapshot_error; #[cfg(test)] mod filesystem_retention_snapshot_tests; mod filesystem_retention_stage; +mod filesystem_retention_stage_refusal; mod filesystem_retention_storage; #[cfg(test)] mod filesystem_retention_storage_tests; @@ -114,6 +115,7 @@ mod verified_closure; mod reader_attempt_limit; mod reader_fence; +mod reader_fence_refusal; mod recovery_evidence; mod recovery_execution; #[cfg(test)] @@ -127,6 +129,9 @@ mod recovery_stage_assessment; mod recovery_storage; #[cfg(test)] mod retention_model_tests; +mod retention_snapshot_refusal; +#[cfg(test)] +mod retention_snapshot_refusal_tests; mod retention_view_collector; #[cfg(test)] mod retention_view_collector_tests; @@ -164,6 +169,7 @@ pub use filesystem_retention_refusal::RetentionCurrentStateRefusal; pub use filesystem_retention_snapshot::FilesystemRetentionSnapshot; pub use filesystem_retention_snapshot_error::FilesystemRetentionSnapshotError; pub(in crate::adapters) use filesystem_retention_stage::FilesystemRetentionStage; +pub use filesystem_retention_stage_refusal::FilesystemRetentionStageRefusal; pub use head_decode_error::RetentionHeadDecodeError; pub use manifest_decode_error::RetentionManifestDecodeError; pub use manifest_encode_error::RetentionManifestEncodeError; @@ -179,6 +185,7 @@ pub use publication_receipt::RetentionPublicationReceipt; pub use publication_storage::RetentionPublicationStorage; pub use reader_attempt_limit::ReaderAttemptLimit; pub use reader_fence::ReaderFence; +pub use reader_fence_refusal::{ReaderFenceKind, ReaderFenceRefusal}; pub use recovery_evidence::{ RetentionPoolEntryObservation, RetentionPoolObservations, RetentionRecoveryEvidence, RetentionStageAssessments, @@ -194,6 +201,7 @@ pub use recovery_stage_assessment::{ RetentionStageAssessment, assess_head_stage, assess_manifest_stage, assess_root_stage, }; pub use recovery_storage::RetentionRecoveryStorage; +pub use retention_snapshot_refusal::RetentionSnapshotRefusal; pub use retention_view_collector::{ RetentionViewCoordinates, RetentionViewError, RetentionViewSource, collect_retention_view, }; diff --git a/src/adapters/retention/filesystem_retention_attempt.rs b/src/adapters/retention/filesystem_retention_attempt.rs index 1edd7f69..4b157d6d 100644 --- a/src/adapters/retention/filesystem_retention_attempt.rs +++ b/src/adapters/retention/filesystem_retention_attempt.rs @@ -40,7 +40,7 @@ pub(super) fn require_mut( } fn no_attempt() -> io::Error { - invalid_data("no admitted retention publication attempt") + invalid_data(super::FilesystemRetentionStageRefusal::NoPublicationAttempt) } impl PublicationAttempt { @@ -77,15 +77,16 @@ impl PublicationAttempt { self.namespace .as_ref() .map(|(_name, namespace)| namespace) - .ok_or_else(|| invalid_data("retention root namespace was not admitted")) + .ok_or_else(|| { + invalid_data(super::FilesystemRetentionStageRefusal::NamespaceNotAdmitted) + }) } /// Returns the admitted namespace only if `name` is the namespace it admitted. pub(super) fn require_namespace(&self, name: &str) -> io::Result<&Dir> { - let (admitted, namespace) = self - .namespace - .as_ref() - .ok_or_else(|| invalid_data("retention root namespace was not admitted"))?; + let (admitted, namespace) = self.namespace.as_ref().ok_or_else(|| { + invalid_data(super::FilesystemRetentionStageRefusal::NamespaceNotAdmitted) + })?; if admitted == name { Ok(namespace) } else { @@ -98,9 +99,9 @@ impl PublicationAttempt { } pub(super) fn retained_root_name(&self) -> io::Result<&str> { - self.retained_root - .as_deref() - .ok_or_else(|| invalid_data("retention root pool coordinate was not retained")) + self.retained_root.as_deref().ok_or_else(|| { + invalid_data(super::FilesystemRetentionStageRefusal::RootPoolNotRetained) + }) } pub(super) fn retain_manifest_name(&mut self, name: String) { @@ -108,9 +109,9 @@ impl PublicationAttempt { } pub(super) fn retained_manifest_name(&self) -> io::Result<&str> { - self.retained_manifest - .as_deref() - .ok_or_else(|| invalid_data("retention manifest pool coordinate was not retained")) + self.retained_manifest.as_deref().ok_or_else(|| { + invalid_data(super::FilesystemRetentionStageRefusal::ManifestPoolNotRetained) + }) } pub(super) fn retain_root_stage(&mut self, stage: FilesystemRetentionStage) { @@ -118,15 +119,15 @@ impl PublicationAttempt { } pub(super) fn root_stage(&self) -> io::Result<&FilesystemRetentionStage> { - self.root_stage - .as_ref() - .ok_or_else(|| invalid_data("retention root stage was not retained")) + self.root_stage.as_ref().ok_or_else(|| { + invalid_data(super::FilesystemRetentionStageRefusal::RootStageNotRetained) + }) } pub(super) fn take_root_stage(&mut self) -> io::Result { - self.root_stage - .take() - .ok_or_else(|| invalid_data("retention root stage was not retained")) + self.root_stage.take().ok_or_else(|| { + invalid_data(super::FilesystemRetentionStageRefusal::RootStageNotRetained) + }) } pub(super) fn retain_manifest_stage(&mut self, stage: FilesystemRetentionStage) { @@ -134,15 +135,15 @@ impl PublicationAttempt { } pub(super) fn manifest_stage(&self) -> io::Result<&FilesystemRetentionStage> { - self.manifest_stage - .as_ref() - .ok_or_else(|| invalid_data("retention manifest stage was not retained")) + self.manifest_stage.as_ref().ok_or_else(|| { + invalid_data(super::FilesystemRetentionStageRefusal::ManifestStageNotRetained) + }) } pub(super) fn take_manifest_stage(&mut self) -> io::Result { - self.manifest_stage - .take() - .ok_or_else(|| invalid_data("retention manifest stage was not retained")) + self.manifest_stage.take().ok_or_else(|| { + invalid_data(super::FilesystemRetentionStageRefusal::ManifestStageNotRetained) + }) } pub(super) fn retain_head_stage(&mut self, stage: FilesystemRetentionStage) { @@ -150,14 +151,14 @@ impl PublicationAttempt { } pub(super) fn head_stage(&self) -> io::Result<&FilesystemRetentionStage> { - self.head_stage - .as_ref() - .ok_or_else(|| invalid_data("retention head stage was not retained")) + self.head_stage.as_ref().ok_or_else(|| { + invalid_data(super::FilesystemRetentionStageRefusal::HeadStageNotRetained) + }) } pub(super) fn take_head_stage(&mut self) -> io::Result { - self.head_stage - .take() - .ok_or_else(|| invalid_data("retention head stage was not retained")) + self.head_stage.take().ok_or_else(|| { + invalid_data(super::FilesystemRetentionStageRefusal::HeadStageNotRetained) + }) } } diff --git a/src/adapters/retention/filesystem_retention_current.rs b/src/adapters/retention/filesystem_retention_current.rs index 11793206..3db065d3 100644 --- a/src/adapters/retention/filesystem_retention_current.rs +++ b/src/adapters/retention/filesystem_retention_current.rs @@ -11,13 +11,25 @@ use super::{ AdmittedRetentionManifest, AdmittedRetentionRoot, ChecksummedRetentionHead, RetentionCurrentStateRefusal, RetentionPublicationPreparation, RetentionTransitionDisposition, }; -use crate::adapters::filesystem_exact_record::{ - self as exact_record, ExactRecordError, ExactRecordRefusal, -}; +use crate::adapters::filesystem_exact_record::{self as exact_record, ExactRecordError}; use crate::{RetentionGenerationExpectation, RetentionHead, RetentionManifest}; const HEAD_LENGTH: usize = super::head_decoder::ENCODED_LENGTH; +fn namespace_read_failure( + namespace: crate::RetentionNamespaceDigest, + source: io::Error, +) -> io::Error { + let kind = match source.kind() { + io::ErrorKind::NotFound => io::ErrorKind::InvalidData, + kind => kind, + }; + io::Error::new( + kind, + RetentionCurrentStateRefusal::NamespaceRead { namespace, source }, + ) +} + /// One published retention head and the manifest it selects, bytes and values. /// /// Both records were reopened without following links, bounded by their @@ -207,7 +219,7 @@ pub(super) fn verify_committed( } let directory = roots .open_dir_nofollow(pool_name::namespace(namespace)) - .map_err(|_source| RetentionCurrentStateRefusal::CommittedNamespaceUnavailable.into_io())?; + .map_err(|source| namespace_read_failure(namespace, source))?; let name = pool_name::root(candidate.root().generation(), candidate.digest()); let observed = read_exact_optional(&directory, &name, candidate.encoded().len())? .ok_or_else(|| RetentionCurrentStateRefusal::CommittedRootAbsent.into_io())?; @@ -241,7 +253,7 @@ pub(super) fn verify_predecessor( } let directory = roots .open_dir_nofollow(pool_name::namespace(namespace)) - .map_err(|_source| RetentionCurrentStateRefusal::CommittedNamespaceUnavailable.into_io())?; + .map_err(|source| namespace_read_failure(namespace, source))?; let name = pool_name::root(entry.root_generation(), entry.root_digest()); let length = match directory.symlink_metadata(&name) { Ok(metadata) => usize::try_from(metadata.len()) @@ -256,8 +268,9 @@ pub(super) fn verify_predecessor( } let bytes = read_exact_optional(&directory, &name, length)? .ok_or_else(|| RetentionCurrentStateRefusal::PredecessorRootAbsent.into_io())?; - let predecessor = AdmittedRetentionRoot::decode(&bytes) - .map_err(|_source| RetentionCurrentStateRefusal::PredecessorRootChanged.into_io())?; + let predecessor = AdmittedRetentionRoot::decode(&bytes).map_err(|source| { + RetentionCurrentStateRefusal::PredecessorRootRefused { source }.into_io() + })?; if predecessor.digest() == entry.root_digest() && predecessor.root().generation() == entry.root_generation() { @@ -285,27 +298,13 @@ fn require_initial_publication( } } -/// Reads one optional exact record, mapping shared refusals onto this protocol's. +/// Reads one optional exact record without erasing shared refusals or OS errors. pub(super) fn read_exact_optional( directory: &Dir, name: &str, length: usize, ) -> io::Result>> { - match exact_record::read_exact_optional(directory, name, length) { - Ok(bytes) => Ok(bytes.map(Vec::into_boxed_slice)), - Err(ExactRecordError::Io(source)) => Err(source), - Err(ExactRecordError::Refused(refusal)) => Err(match refusal { - ExactRecordRefusal::LengthOverflow => { - RetentionCurrentStateRefusal::RecordLengthOverflow - } - ExactRecordRefusal::TrailingBytes => RetentionCurrentStateRefusal::RecordTrailingBytes, - ExactRecordRefusal::KindOrLength - | ExactRecordRefusal::KindLengthOrIdentity - | ExactRecordRefusal::Bytes - | ExactRecordRefusal::RemainedVisible => { - RetentionCurrentStateRefusal::RecordKindOrLength - } - } - .into_io()), - } + exact_record::read_exact_optional(directory, name, length) + .map(|bytes| bytes.map(Vec::into_boxed_slice)) + .map_err(ExactRecordError::into_io) } diff --git a/src/adapters/retention/filesystem_retention_disposition.rs b/src/adapters/retention/filesystem_retention_disposition.rs index 438745c0..e2e614f4 100644 --- a/src/adapters/retention/filesystem_retention_disposition.rs +++ b/src/adapters/retention/filesystem_retention_disposition.rs @@ -331,12 +331,14 @@ impl FilesystemRetentionPublicationAuthority { fence: &ReaderFence, ) -> io::Result { let head_bytes = read_exact_optional(&self.root, HEAD_NAME, HEAD_LENGTH)? - .ok_or_else(|| invalid_data("publication head is absent"))?; + .ok_or_else(|| invalid_data(super::FilesystemRetentionStageRefusal::HeadAbsent))?; let head = ChecksummedPublicationHead::decode(&head_bytes).map_err(invalid_data_from)?; let checksum: [u8; 32] = head_bytes .get(HEAD_CHECKSUM_OFFSET..HEAD_LENGTH) .and_then(|slice| slice.try_into().ok()) - .ok_or_else(|| invalid_data("publication head checksum slot"))?; + .ok_or_else(|| { + invalid_data(super::FilesystemRetentionStageRefusal::HeadChecksumSlot) + })?; let retention = filesystem_retention_current::observe(&self.retention, &self.manifests)? .map_or(GcRetentionState::Empty, |current| { GcRetentionState::Published { diff --git a/src/adapters/retention/filesystem_retention_disposition_evidence.rs b/src/adapters/retention/filesystem_retention_disposition_evidence.rs index 106cd582..8023541d 100644 --- a/src/adapters/retention/filesystem_retention_disposition_evidence.rs +++ b/src/adapters/retention/filesystem_retention_disposition_evidence.rs @@ -35,7 +35,9 @@ pub(super) fn disposed_artifact( RecoveryDispositionTarget::Root => { let bytes = observation .root() - .ok_or_else(|| invalid_data("disposition target root stage vanished"))? + .ok_or_else(|| { + invalid_data(super::FilesystemRetentionStageRefusal::DispositionRootStageAbsent) + })? .bytes .clone(); let root = AdmittedRetentionRoot::decode(&bytes).map_err(invalid_data_from)?; @@ -48,7 +50,11 @@ pub(super) fn disposed_artifact( RecoveryDispositionTarget::Manifest => { let bytes = observation .manifest() - .ok_or_else(|| invalid_data("disposition target manifest stage vanished"))? + .ok_or_else(|| { + invalid_data( + super::FilesystemRetentionStageRefusal::DispositionManifestStageAbsent, + ) + })? .bytes .clone(); let manifest = AdmittedRetentionManifest::decode(&bytes).map_err(invalid_data_from)?; @@ -107,14 +113,13 @@ pub(super) fn receipt_for( /// The artifact record's trailing checksum: the evidence the decision was /// made over. pub(super) fn trailing_checksum(bytes: &[u8]) -> io::Result<[u8; 32]> { - let start = bytes - .len() - .checked_sub(32) - .ok_or_else(|| invalid_data("artifact is shorter than its checksum"))?; + let start = bytes.len().checked_sub(32).ok_or_else(|| { + invalid_data(super::FilesystemRetentionStageRefusal::ArtifactChecksumLength) + })?; bytes .get(start..) .and_then(|slice| slice.try_into().ok()) - .ok_or_else(|| invalid_data("artifact checksum slot")) + .ok_or_else(|| invalid_data(super::FilesystemRetentionStageRefusal::ArtifactChecksumSlot)) } /// The first regular entry of `directory` whose name ends with `suffix`, @@ -127,11 +132,14 @@ pub(super) fn entry_with_suffix(directory: &Dir, suffix: &str) -> io::Result io::Result return Err(source), }; if !file.metadata()?.is_file() { - return Err(invalid_data("disposition stage is not a regular file")); + return Err(invalid_data( + super::FilesystemRetentionStageRefusal::DispositionStageKind, + )); } let mut bytes = Vec::new(); let limit = u64::try_from(RECEIPT_LENGTH) @@ -158,6 +168,6 @@ pub(super) fn read_bounded(recovery: &Dir, name: &str) -> io::Result io::Result<()> { recovery.remove_file(pool_name::DISPOSITION_STAGE)?; exact_record::require_absent(recovery, pool_name::DISPOSITION_STAGE) - .map_err(|_source| invalid_data("discarded disposition stage remained visible"))?; + .map_err(exact_record::ExactRecordError::into_io)?; synchronize_directory(recovery) } diff --git a/src/adapters/retention/filesystem_retention_disposition_storage.rs b/src/adapters/retention/filesystem_retention_disposition_storage.rs index 8ead6862..f5d01285 100644 --- a/src/adapters/retention/filesystem_retention_disposition_storage.rs +++ b/src/adapters/retention/filesystem_retention_disposition_storage.rs @@ -15,21 +15,20 @@ use crate::adapters::filesystem_catalog_artifact::synchronize_directory; use crate::adapters::filesystem_exact_record as exact_record; fn no_disposition() -> io::Error { - invalid_data("no disposition is in progress") + invalid_data(super::FilesystemRetentionStageRefusal::NoDisposition) } /// Removes `name` from `directory` after proving it still holds `expected`. fn unlink_verified(directory: &Dir, name: &str, expected: &[u8]) -> io::Result<()> { let observed = read_exact_optional(directory, name, expected.len())? - .ok_or_else(|| invalid_data("retired pool entry is already absent"))?; + .ok_or_else(|| invalid_data(super::FilesystemRetentionStageRefusal::PoolEntryAbsent))?; if observed.as_ref() != expected { return Err(invalid_data( - "retired pool entry bytes disagree with the receipt", + super::FilesystemRetentionStageRefusal::PoolEntryChanged, )); } directory.remove_file(name)?; - exact_record::require_absent(directory, name) - .map_err(|_source| invalid_data("retired pool entry remained visible")) + exact_record::require_absent(directory, name).map_err(exact_record::ExactRecordError::into_io) } impl RecoveryDispositionStorage for FilesystemRetentionPublicationAuthority { diff --git a/src/adapters/retention/filesystem_retention_expectation_tests.rs b/src/adapters/retention/filesystem_retention_expectation_tests.rs index 91828cef..86cdfeb8 100644 --- a/src/adapters/retention/filesystem_retention_expectation_tests.rs +++ b/src/adapters/retention/filesystem_retention_expectation_tests.rs @@ -156,7 +156,14 @@ fn successor_refuses_when_the_predecessor_root_bytes_changed() -> Result<(), Box assert!(matches!( refusal(&error), - Some(RetentionCurrentStateRefusal::PredecessorRootChanged) + Some(RetentionCurrentStateRefusal::PredecessorRootRefused { .. }) + )); + let cause = refusal(&error) + .and_then(Error::source) + .and_then(|source| source.downcast_ref::()); + assert!(matches!( + cause, + Some(crate::RetentionRootDecodeError::ChecksumMismatch { .. }) )); Ok(()) } diff --git a/src/adapters/retention/filesystem_retention_recovery.rs b/src/adapters/retention/filesystem_retention_recovery.rs index 5cbb1c41..36df10d0 100644 --- a/src/adapters/retention/filesystem_retention_recovery.rs +++ b/src/adapters/retention/filesystem_retention_recovery.rs @@ -147,7 +147,7 @@ impl FilesystemRetentionPublicationAuthority { } fn no_recovery() -> io::Error { - invalid_data("no retention recovery is in progress") + invalid_data(super::FilesystemRetentionStageRefusal::NoRecovery) } fn take_complete( @@ -161,9 +161,13 @@ fn take_complete( }) => Ok((stage, pool_name, namespace)), Some(other) => { *slot = Some(other); - Err(invalid_data("recovery step expected a complete stage")) + Err(invalid_data( + super::FilesystemRetentionStageRefusal::RecoveryRequiresCompleteStage, + )) } - None => Err(invalid_data("recovery step expected a retained stage")), + None => Err(invalid_data( + super::FilesystemRetentionStageRefusal::NoRetainedRecoveryStage, + )), } } @@ -173,18 +177,20 @@ fn discard_truncated( slot: &mut Option, ) -> io::Result<()> { let Some(RecoveredStage::Truncated { identity, length }) = slot.take() else { - return Err(invalid_data("recovery step expected a truncated stage")); + return Err(invalid_data( + super::FilesystemRetentionStageRefusal::RecoveryRequiresTruncatedStage, + )); }; let metadata = retention.symlink_metadata(name)?; if !metadata.is_file() || metadata.len() != length || EntryIdentity::from(&metadata) != identity { return Err(invalid_data( - "truncated retention stage changed before discard", + super::FilesystemRetentionStageRefusal::TruncatedStageChanged, )); } retention.remove_file(name)?; exact_record::require_absent(retention, name) - .map_err(|_source| invalid_data("discarded retention stage remained visible"))?; + .map_err(exact_record::ExactRecordError::into_io)?; synchronize_directory(retention) } @@ -216,7 +222,9 @@ impl RetentionRecoveryStorage for FilesystemRetentionPublicationAuthority { namespace: Some(namespace), }) = context.root.as_ref() else { - return Err(invalid_data("link_root expected a complete root stage")); + return Err(invalid_data( + super::FilesystemRetentionStageRefusal::RootLinkRequiresCompleteStage, + )); }; match self.roots.create_dir(namespace) { Ok(()) => {} @@ -238,7 +246,7 @@ impl RetentionRecoveryStorage for FilesystemRetentionPublicationAuthority { }) = context.manifest.as_ref() else { return Err(invalid_data( - "link_manifest expected a complete manifest stage", + super::FilesystemRetentionStageRefusal::ManifestLinkRequiresCompleteStage, )); }; stage.link(&self.retention, &self.manifests, name)?; @@ -255,7 +263,9 @@ impl RetentionRecoveryStorage for FilesystemRetentionPublicationAuthority { fn remove_root_stage(&mut self) -> io::Result<()> { let context = self.recovery.as_mut().ok_or_else(no_recovery)?; let (stage, name, namespace) = take_complete(&mut context.root)?; - let namespace = namespace.ok_or_else(|| invalid_data("root stage without a namespace"))?; + let namespace = namespace.ok_or_else(|| { + invalid_data(super::FilesystemRetentionStageRefusal::RootNamespaceAbsent) + })?; let directory = self.roots.open_dir_nofollow(&namespace)?; stage.remove(&self.retention, &directory, &name)?; synchronize_directory(&self.retention) diff --git a/src/adapters/retention/filesystem_retention_recovery_observation.rs b/src/adapters/retention/filesystem_retention_recovery_observation.rs index 9b21fc8e..c49d1330 100644 --- a/src/adapters/retention/filesystem_retention_recovery_observation.rs +++ b/src/adapters/retention/filesystem_retention_recovery_observation.rs @@ -127,14 +127,19 @@ fn read_stage(retention: &Dir, name: &str, bound: usize) -> io::Result { "committed manifest selects a different root for the candidate namespace" } - Self::CommittedNamespaceUnavailable => { + Self::CommittedNamespaceUnavailable | Self::NamespaceRead { .. } => { "committed root namespace directory is unavailable" } Self::CommittedRootAbsent => "committed root pool entry is absent", @@ -243,7 +257,7 @@ impl RetentionCurrentStateRefusal { Self::PredecessorRootAbsent => { "predecessor root pool entry is absent or exceeds the format bound" } - Self::PredecessorRootChanged => { + Self::PredecessorRootChanged | Self::PredecessorRootRefused { .. } => { "predecessor root pool entry does not decode to the manifest's selection" } Self::UnknownRetentionEntry => "retention namespace carries an unknown entry", @@ -297,6 +311,8 @@ impl Error for RetentionCurrentStateRefusal { Self::RecoveryStepRefused { source } => Some(source), Self::ClosureMemberRefused { source } => Some(source.as_ref()), Self::ClosureReverificationRefused { source } => Some(source), + Self::NamespaceRead { source, .. } => Some(source), + Self::PredecessorRootRefused { source } => Some(source), _ => None, } } diff --git a/src/adapters/retention/filesystem_retention_snapshot.rs b/src/adapters/retention/filesystem_retention_snapshot.rs index 3ab90aa1..a59318e7 100644 --- a/src/adapters/retention/filesystem_retention_snapshot.rs +++ b/src/adapters/retention/filesystem_retention_snapshot.rs @@ -18,7 +18,7 @@ use crate::adapters::{ CatalogRestartPolicy, ChecksummedPublicationHead, FilesystemCatalogSnapshot, filesystem_initialization_namespace, filesystem_version_two_records, publication_head_decoder, }; -use crate::{RetentionHead, RetentionManifest, RetentionNamespaceDigest}; +use crate::{RetentionHead, RetentionManifest, RetentionNamespaceDigest, RetentionSnapshotRefusal}; const HEAD_NAME: &str = "HEAD"; @@ -202,27 +202,44 @@ impl FilesystemRetentionSnapshot { else { return Ok(None); }; + let bytes = self.read_selected_root(entry)?; + require_selected_root(&bytes, entry)?; + Ok(Some(bytes.into_boxed_slice())) + } + + fn read_selected_root(&self, entry: crate::RetentionManifestEntry) -> Result, Error> { let directory = self .roots - .open_dir_nofollow(pool_name::namespace(namespace)) + .open_dir_nofollow(pool_name::namespace(entry.namespace())) .map_err(|source| Error::Root { source })?; let name = pool_name::root(entry.root_generation(), entry.root_digest()); let length = directory .symlink_metadata(&name) .and_then(|metadata| { - usize::try_from(metadata.len()).map_err(|_source| invalid("root length overflow")) + usize::try_from(metadata.len()).map_err(|source| { + invalid(RetentionSnapshotRefusal::LengthAddressSpace { + observed: metadata.len(), + source, + }) + }) }) .map_err(|source| Error::Root { source })?; if length > root_header_decoder::MAXIMUM_ENCODED_LENGTH { return Err(Error::Root { - source: invalid("selected root exceeds the format bound"), + source: invalid(RetentionSnapshotRefusal::LengthBound { + maximum: root_header_decoder::MAXIMUM_ENCODED_LENGTH, + observed: length, + }), }); } let bytes = match exact_record::read_exact_optional(&directory, &name, length) { Ok(Some(bytes)) => bytes, Ok(None) => { return Err(Error::Root { - source: invalid("selected root is absent"), + source: invalid(RetentionSnapshotRefusal::SelectedRootAbsent { + expected_generation: entry.root_generation(), + expected_digest: entry.root_digest(), + }), }); } Err(ExactRecordError::Io(source)) => return Err(Error::Root { source }), @@ -235,20 +252,27 @@ impl FilesystemRetentionSnapshot { }); } }; - let root = AdmittedRetentionRoot::decode(&bytes).map_err(|source| Error::Root { - source: io::Error::new(io::ErrorKind::InvalidData, source), - })?; - if root.digest() != entry.root_digest() - || root.root().generation() != entry.root_generation() - { - return Err(Error::Root { - source: invalid("selected root does not decode to the manifest's selection"), - }); - } - Ok(Some(bytes.into_boxed_slice())) + Ok(bytes) + } +} + +fn require_selected_root(bytes: &[u8], entry: crate::RetentionManifestEntry) -> Result<(), Error> { + let root = AdmittedRetentionRoot::decode(bytes).map_err(|source| Error::Root { + source: io::Error::new(io::ErrorKind::InvalidData, source), + })?; + if root.digest() != entry.root_digest() || root.root().generation() != entry.root_generation() { + return Err(Error::Root { + source: invalid(RetentionSnapshotRefusal::SelectionMismatch { + expected_generation: entry.root_generation(), + observed_generation: root.root().generation(), + expected_digest: entry.root_digest(), + observed_digest: root.digest(), + }), + }); } + Ok(()) } -fn invalid(message: &'static str) -> io::Error { - io::Error::new(io::ErrorKind::InvalidData, message) +fn invalid(refusal: RetentionSnapshotRefusal) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, refusal) } diff --git a/src/adapters/retention/filesystem_retention_snapshot_tests.rs b/src/adapters/retention/filesystem_retention_snapshot_tests.rs index 8f45ae78..b93c2867 100644 --- a/src/adapters/retention/filesystem_retention_snapshot_tests.rs +++ b/src/adapters/retention/filesystem_retention_snapshot_tests.rs @@ -116,9 +116,19 @@ fn a_replaced_reader_lock_refuses_the_fence() -> Result<(), Box> { FilesystemRetentionSnapshot::load(sandbox.path(), policy()?, ReaderAttemptLimit::DEFAULT) .err() .ok_or("a non-empty reader.lock was accepted as the fence")?; - assert!(matches!( - error, - FilesystemRetentionSnapshotError::Fence { .. } - )); + let FilesystemRetentionSnapshotError::Fence { source } = error else { + return Err("wrong fence refusal boundary".into()); + }; + assert_eq!(source.kind(), std::io::ErrorKind::InvalidData); + assert_eq!( + source + .get_ref() + .and_then(|source| source.downcast_ref::()), + Some(&crate::ReaderFenceRefusal::Length { + expected: 0, + observed_handle: 9, + observed_entry: 9, + }) + ); Ok(()) } diff --git a/src/adapters/retention/filesystem_retention_stage.rs b/src/adapters/retention/filesystem_retention_stage.rs index 6951a4c2..a19626f1 100644 --- a/src/adapters/retention/filesystem_retention_stage.rs +++ b/src/adapters/retention/filesystem_retention_stage.rs @@ -6,7 +6,7 @@ use cap_std::fs::{Dir, File}; use crate::adapters::filesystem_catalog_artifact; use crate::adapters::filesystem_exact_record::{ - self as exact_record, EntryIdentity, ExactRecordError, ExactRecordRefusal, + self as exact_record, EntryIdentity, ExactRecordError, }; /// One exclusively created, verified, and retained retention stage file. @@ -102,7 +102,9 @@ impl FilesystemRetentionStage { if EntryIdentity::of_file(&self.file)? == self.identity { Ok(()) } else { - Err(invalid_data("retention stage handle changed identity")) + Err(invalid_data( + super::FilesystemRetentionStageRefusal::HandleIdentityChanged, + )) } } @@ -121,23 +123,11 @@ fn verify_named_record( exact_record::verify_named(directory, name, expected, identity).map_err(retention_error) } -/// Maps a shared exact-record failure onto this protocol's refusal messages. +/// Retains exact-record refusals and the filesystem's original operational errors. fn retention_error(error: ExactRecordError) -> io::Error { - match error { - ExactRecordError::Io(source) => source, - ExactRecordError::Refused(refusal) => invalid_data(match refusal { - ExactRecordRefusal::LengthOverflow => "retention record length exceeded u64", - ExactRecordRefusal::KindOrLength | ExactRecordRefusal::KindLengthOrIdentity => { - "retention record kind, length, or identity disagreed" - } - ExactRecordRefusal::Bytes | ExactRecordRefusal::TrailingBytes => { - "retention record bytes disagreed" - } - ExactRecordRefusal::RemainedVisible => "removed retention stage remained visible", - }), - } + error.into_io() } -pub(super) fn invalid_data(message: &'static str) -> io::Error { - io::Error::new(io::ErrorKind::InvalidData, message) +pub(super) fn invalid_data(refusal: super::FilesystemRetentionStageRefusal) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, refusal) } diff --git a/src/adapters/retention/filesystem_retention_stage_refusal.rs b/src/adapters/retention/filesystem_retention_stage_refusal.rs new file mode 100644 index 00000000..265e158c --- /dev/null +++ b/src/adapters/retention/filesystem_retention_stage_refusal.rs @@ -0,0 +1,111 @@ +//! This module owns retention filesystem protocol-state refusals. + +use std::error::Error; +use std::fmt; + +/// A semantic filesystem protocol refusal, preserved through I/O adapters. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum FilesystemRetentionStageRefusal { + /// A retained stage is not a regular file. + NonRegularRecoveryStage, + /// The checked bound cannot include a corruption witness. + RecoveryStageBoundOverflow, + /// No admitted retention publication attempt. + NoPublicationAttempt, + /// Retention root namespace was not admitted. + NamespaceNotAdmitted, + /// Retention root pool coordinate was not retained. + RootPoolNotRetained, + /// Retention manifest pool coordinate was not retained. + ManifestPoolNotRetained, + /// Retention root stage was not retained. + RootStageNotRetained, + /// Retention manifest stage was not retained. + ManifestStageNotRetained, + /// Retention head stage was not retained. + HeadStageNotRetained, + /// No disposition is in progress. + NoDisposition, + /// Retired pool entry is already absent. + PoolEntryAbsent, + /// Retired pool entry bytes disagree with the receipt. + PoolEntryChanged, + /// Disposition target root stage vanished. + DispositionRootStageAbsent, + /// Disposition target manifest stage vanished. + DispositionManifestStageAbsent, + /// Artifact is shorter than its checksum. + ArtifactChecksumLength, + /// Artifact checksum slot. + ArtifactChecksumSlot, + /// Retention pool entry is not a regular file. + PoolEntryKind, + /// Retention pool entry vanished. + PoolEntryVanished, + /// Disposition stage is not a regular file. + DispositionStageKind, + /// Retention stage handle changed identity. + HandleIdentityChanged, + /// Publication head is absent. + HeadAbsent, + /// Publication head checksum slot. + HeadChecksumSlot, + /// No retention recovery is in progress. + NoRecovery, + /// Recovery step expected a complete stage. + RecoveryRequiresCompleteStage, + /// Recovery step expected a retained stage. + NoRetainedRecoveryStage, + /// Recovery step expected a truncated stage. + RecoveryRequiresTruncatedStage, + /// Truncated retention stage changed before discard. + TruncatedStageChanged, + /// Root linking requires a complete root stage. + RootLinkRequiresCompleteStage, + /// Manifest linking requires a complete manifest stage. + ManifestLinkRequiresCompleteStage, + /// Root stage without a namespace. + RootNamespaceAbsent, +} + +impl fmt::Display for FilesystemRetentionStageRefusal { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::NonRegularRecoveryStage => "retained retention stage is not a regular file", + Self::RecoveryStageBoundOverflow => "stage bound overflowed", + Self::NoPublicationAttempt => "no admitted retention publication attempt", + Self::NamespaceNotAdmitted => "retention root namespace was not admitted", + Self::RootPoolNotRetained => "retention root pool coordinate was not retained", + Self::ManifestPoolNotRetained => "retention manifest pool coordinate was not retained", + Self::RootStageNotRetained => "retention root stage was not retained", + Self::ManifestStageNotRetained => "retention manifest stage was not retained", + Self::HeadStageNotRetained => "retention head stage was not retained", + Self::NoDisposition => "no disposition is in progress", + Self::PoolEntryAbsent => "retired pool entry is already absent", + Self::PoolEntryChanged => "retired pool entry bytes disagree with the receipt", + Self::DispositionRootStageAbsent => "disposition target root stage vanished", + Self::DispositionManifestStageAbsent => "disposition target manifest stage vanished", + Self::ArtifactChecksumLength => "artifact is shorter than its checksum", + Self::ArtifactChecksumSlot => "artifact checksum slot", + Self::PoolEntryKind => "retention pool entry is not a regular file", + Self::PoolEntryVanished => "retention pool entry vanished", + Self::DispositionStageKind => "disposition stage is not a regular file", + Self::HandleIdentityChanged => "retention stage handle changed identity", + Self::HeadAbsent => "publication head is absent", + Self::HeadChecksumSlot => "publication head checksum slot", + Self::NoRecovery => "no retention recovery is in progress", + Self::RecoveryRequiresCompleteStage => "recovery step expected a complete stage", + Self::NoRetainedRecoveryStage => "recovery step expected a retained stage", + Self::RecoveryRequiresTruncatedStage => "recovery step expected a truncated stage", + Self::TruncatedStageChanged => "truncated retention stage changed before discard", + Self::RootLinkRequiresCompleteStage => "link_root expected a complete root stage", + Self::ManifestLinkRequiresCompleteStage => { + "link_manifest expected a complete manifest stage" + } + Self::RootNamespaceAbsent => "root stage without a namespace", + }) + } +} + +impl Error for FilesystemRetentionStageRefusal {} diff --git a/src/adapters/retention/filesystem_retention_storage_tests.rs b/src/adapters/retention/filesystem_retention_storage_tests.rs index 697fd551..875f7087 100644 --- a/src/adapters/retention/filesystem_retention_storage_tests.rs +++ b/src/adapters/retention/filesystem_retention_storage_tests.rs @@ -103,6 +103,17 @@ fn byte_equal_substituted_canonical_root_is_refused() -> Result<(), Box()), + Some( + crate::adapters::filesystem_exact_record::ExactRecordError::Refused( + crate::adapters::filesystem_exact_record::ExactRecordRefusal::KindLengthOrIdentity + ) + ) + )); Ok(()) } diff --git a/src/adapters/retention/reader_fence.rs b/src/adapters/retention/reader_fence.rs index 7a49b5b1..81bcf637 100644 --- a/src/adapters/retention/reader_fence.rs +++ b/src/adapters/retention/reader_fence.rs @@ -6,6 +6,7 @@ use cap_fs_ext::MetadataExt; use cap_std::fs::{Dir, File, Metadata}; use rustix::fs::{FlockOperation, flock}; +use super::{ReaderFenceKind, ReaderFenceRefusal}; use crate::adapters::filesystem_exact_record; const READER_LOCK: &str = "reader.lock"; @@ -59,21 +60,41 @@ impl ReaderFence { fn verify(root: &Dir, file: &File) -> io::Result<()> { let handle = file.metadata()?; let entry = root.symlink_metadata(READER_LOCK)?; - if handle.is_file() - && entry.is_file() - && handle.len() == 0 - && entry.len() == 0 - && identity(&handle) == identity(&entry) - { - Ok(()) + if !handle.is_file() || !entry.is_file() { + return Err(refused(ReaderFenceRefusal::Kind { + expected: ReaderFenceKind::RegularFile, + observed_handle: kind(&handle), + observed_entry: kind(&entry), + })); + } + if handle.len() != 0 || entry.len() != 0 { + return Err(refused(ReaderFenceRefusal::Length { + expected: 0, + observed_handle: handle.len(), + observed_entry: entry.len(), + })); + } + if identity(&handle) != identity(&entry) { + return Err(refused(ReaderFenceRefusal::Identity { + expected: identity(&handle), + observed: identity(&entry), + })); + } + Ok(()) +} + +fn kind(metadata: &Metadata) -> ReaderFenceKind { + if metadata.is_file() { + ReaderFenceKind::RegularFile } else { - Err(io::Error::new( - io::ErrorKind::InvalidData, - "reader fence kind, length, or identity disagreed", - )) + ReaderFenceKind::Other } } +fn refused(refusal: ReaderFenceRefusal) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, refusal) +} + fn identity(metadata: &Metadata) -> (u64, u64) { (metadata.dev(), metadata.ino()) } diff --git a/src/adapters/retention/reader_fence_refusal.rs b/src/adapters/retention/reader_fence_refusal.rs new file mode 100644 index 00000000..0ddef49d --- /dev/null +++ b/src/adapters/retention/reader_fence_refusal.rs @@ -0,0 +1,66 @@ +//! This module owns precise reader-fence kind, length, and identity refusals. + +use std::error::Error; +use std::fmt; + +/// The file kind observed at the reader fence boundary. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ReaderFenceKind { + /// A regular file, as the protocol requires. + RegularFile, + /// A directory, symlink, device, or other non-regular entry. + Other, +} + +/// Why the opened handle and named fence cannot protect one reader view. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum ReaderFenceRefusal { + /// The fence handle or current entry is not a regular file. + Kind { + /// The protocol's required kind. + expected: ReaderFenceKind, + /// The opened handle's kind. + observed_handle: ReaderFenceKind, + /// The current entry's kind. + observed_entry: ReaderFenceKind, + }, + /// The fence is not empty. + Length { + /// The required zero length. + expected: u64, + /// The opened handle's length. + observed_handle: u64, + /// The current entry's length. + observed_entry: u64, + }, + /// The name no longer identifies the opened fence handle. + Identity { + /// The opened handle's device and inode. + expected: (u64, u64), + /// The current entry's device and inode. + observed: (u64, u64), + }, +} + +impl fmt::Display for ReaderFenceRefusal { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Kind { .. } => formatter.write_str("reader fence is not a regular file"), + Self::Length { + observed_handle, + observed_entry, + .. + } => write!( + formatter, + "reader fence must be empty: handle length {observed_handle}, entry length {observed_entry}" + ), + Self::Identity { expected, observed } => write!( + formatter, + "reader fence changed identity: expected {expected:?}, observed {observed:?}" + ), + } + } +} + +impl Error for ReaderFenceRefusal {} diff --git a/src/adapters/retention/retention_snapshot_refusal.rs b/src/adapters/retention/retention_snapshot_refusal.rs new file mode 100644 index 00000000..032b1929 --- /dev/null +++ b/src/adapters/retention/retention_snapshot_refusal.rs @@ -0,0 +1,86 @@ +//! This module owns semantic refusals when a manifest selects a retained root. + +use std::error::Error; +use std::fmt; +use std::num::TryFromIntError; + +use crate::{RetentionRootDigest, RootGeneration}; + +/// Why a selected root's size or decoded selection cannot be admitted. +#[derive(Debug)] +#[non_exhaustive] +pub enum RetentionSnapshotRefusal { + /// The filesystem length cannot be represented in this address space. + LengthAddressSpace { + /// The observed file length. + observed: u64, + /// The exact conversion failure. + source: TryFromIntError, + }, + /// The root exceeds the protocol's allocation bound. + LengthBound { + /// The maximum encoded length admitted by the root format. + maximum: usize, + /// The filesystem length observed before allocation. + observed: usize, + }, + /// A selected record disappeared between observation and exact reading. + SelectedRootAbsent { + /// The generation the manifest selects. + expected_generation: RootGeneration, + /// The digest the manifest selects. + expected_digest: RetentionRootDigest, + }, + /// The decoded root is not the manifest's exact selection. + SelectionMismatch { + /// The manifest-selected generation. + expected_generation: RootGeneration, + /// The decoded generation. + observed_generation: RootGeneration, + /// The manifest-selected digest. + expected_digest: RetentionRootDigest, + /// The decoded digest. + observed_digest: RetentionRootDigest, + }, +} + +impl fmt::Display for RetentionSnapshotRefusal { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::LengthAddressSpace { observed, .. } => { + write!( + formatter, + "selected root length {observed} exceeds the address space" + ) + } + Self::LengthBound { maximum, observed } => { + write!( + formatter, + "selected root length {observed} exceeds format bound {maximum}" + ) + } + Self::SelectedRootAbsent { + expected_generation, + .. + } => { + write!( + formatter, + "selected root generation {expected_generation:?} is absent" + ) + } + Self::SelectionMismatch { .. } => formatter + .write_str("selected root disagrees with the manifest's generation or digest"), + } + } +} + +impl Error for RetentionSnapshotRefusal { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::LengthAddressSpace { source, .. } => Some(source), + Self::LengthBound { .. } + | Self::SelectedRootAbsent { .. } + | Self::SelectionMismatch { .. } => None, + } + } +} diff --git a/src/adapters/retention/retention_snapshot_refusal_tests.rs b/src/adapters/retention/retention_snapshot_refusal_tests.rs new file mode 100644 index 00000000..24a5f0bb --- /dev/null +++ b/src/adapters/retention/retention_snapshot_refusal_tests.rs @@ -0,0 +1,98 @@ +//! Selected-root refusals retain the precise bound and manifest coordinates. + +use std::error::Error; +use std::fs::{self, OpenOptions}; + +use super::filesystem_retention_test_fixture::{ + ROOT_HEX, catalog_policy, fixture, initial_preparation, open_authority, root_pool_path, +}; +use crate::{ + AdmittedRetentionRoot, CanonicalRetentionRoot, FilesystemRetentionSnapshot, + FilesystemRetentionSnapshotError, ReaderAttemptLimit, RetentionPolicy, RetentionRoot, + RetentionSnapshotRefusal, +}; + +#[test] +fn an_oversized_selected_root_reports_the_exact_allocation_bound() -> Result<(), Box> { + let (sandbox, mut authority) = open_authority("snapshot-root-bound-source")?; + let bytes = fixture(ROOT_HEX)?; + let selected = AdmittedRetentionRoot::decode(&bytes)?; + let preparation = initial_preparation(&bytes)?; + let _receipt = crate::execute_retention_publication(&mut authority, &preparation)?; + drop(authority); + let snapshot = FilesystemRetentionSnapshot::load( + sandbox.path(), + catalog_policy()?, + ReaderAttemptLimit::DEFAULT, + )?; + let head = fs::read(sandbox.path().join("retention/HEAD"))?; + let maximum = super::root_header_decoder::MAXIMUM_ENCODED_LENGTH; + let observed = maximum.checked_add(1).ok_or("bound overflow")?; + OpenOptions::new() + .write(true) + .open(root_pool_path(sandbox.path(), &selected))? + .set_len(u64::try_from(observed)?)?; + let error = snapshot + .retained_root(selected.root().namespace().digest()) + .err() + .ok_or("oversized root admitted")?; + let source = root_source(error)?; + assert!(matches!( + source.get_ref().and_then(|error| error.downcast_ref::()), + Some(RetentionSnapshotRefusal::LengthBound { maximum: limit, observed: length }) + if *limit == maximum && *length == observed + )); + assert_eq!(fs::read(sandbox.path().join("retention/HEAD"))?, head); + drop(snapshot); + sandbox.remove()?; + Ok(()) +} + +#[test] +fn a_valid_successor_at_the_selected_name_reports_both_selections() -> Result<(), Box> { + let (sandbox, mut authority) = open_authority("snapshot-root-selection-source")?; + let bytes = fixture(ROOT_HEX)?; + let selected = AdmittedRetentionRoot::decode(&bytes)?; + let preparation = initial_preparation(&bytes)?; + let _receipt = crate::execute_retention_publication(&mut authority, &preparation)?; + drop(authority); + let successor = RetentionRoot::new( + selected.root().namespace().clone(), + selected.root().generation().successor()?, + RetentionPolicy::new(selected.root().profile(), selected.root().limits()), + Some(selected.digest()), + selected.root().anchors().to_vec(), + )?; + let encoded = CanonicalRetentionRoot::from_root(&successor)?; + let snapshot = FilesystemRetentionSnapshot::load( + sandbox.path(), + catalog_policy()?, + ReaderAttemptLimit::DEFAULT, + )?; + let head = fs::read(sandbox.path().join("retention/HEAD"))?; + fs::write(root_pool_path(sandbox.path(), &selected), encoded.encoded())?; + let error = snapshot + .retained_root(selected.root().namespace().digest()) + .err() + .ok_or("different valid root admitted under the selected name")?; + let source = root_source(error)?; + assert!(matches!( + source.get_ref().and_then(|error| error.downcast_ref::()), + Some(RetentionSnapshotRefusal::SelectionMismatch { + expected_generation, observed_generation, expected_digest, observed_digest, + }) if *expected_generation == selected.root().generation() + && *observed_generation == successor.generation() + && *expected_digest == selected.digest() && *observed_digest == encoded.digest() + )); + assert_eq!(fs::read(sandbox.path().join("retention/HEAD"))?, head); + drop(snapshot); + sandbox.remove()?; + Ok(()) +} + +fn root_source(error: FilesystemRetentionSnapshotError) -> Result> { + match error { + FilesystemRetentionSnapshotError::Root { source } => Ok(source), + error => Err(Box::new(error)), + } +} diff --git a/src/adapters/store_migration.rs b/src/adapters/store_migration.rs index 9ed72e1e..0fad4a78 100644 --- a/src/adapters/store_migration.rs +++ b/src/adapters/store_migration.rs @@ -47,6 +47,7 @@ mod filesystem_migration_reader_fence; mod filesystem_migration_recovery; #[cfg(test)] mod filesystem_migration_recovery_tests; +mod filesystem_migration_refusal; #[cfg(feature = "repository-tasks")] mod filesystem_migration_repository_tasks; mod filesystem_migration_residue; @@ -122,6 +123,7 @@ pub use filesystem_migration_authority_error::{ FilesystemMigrationAuthorityArtifact, FilesystemMigrationAuthorityError, StoreRootIdentityCoordinate, }; +pub use filesystem_migration_refusal::FilesystemMigrationRefusal; pub use format_definition_digest::StoreFormatDefinitionDigest; pub use format_marker_decode_error::StoreFormatMarkerDecodeError; pub(super) use format_marker_decoder::ENCODED_LENGTH as FORMAT_MARKER_LENGTH; diff --git a/src/adapters/store_migration/filesystem_migration_authority.rs b/src/adapters/store_migration/filesystem_migration_authority.rs index 8a8a958b..5a0a7fbb 100644 --- a/src/adapters/store_migration/filesystem_migration_authority.rs +++ b/src/adapters/store_migration/filesystem_migration_authority.rs @@ -188,7 +188,7 @@ impl FilesystemStoreMigrationAuthority { Ok(Some(_entry)) => Err(Error::Namespace { source: std::io::Error::new( std::io::ErrorKind::InvalidData, - "version-one staging holds a retained stage; recover it before migration", + super::FilesystemMigrationRefusal::VersionOneStageRequiresRecovery, ), }), Err(source) => Err(Error::Namespace { source }), diff --git a/src/adapters/store_migration/filesystem_migration_fixed_artifact.rs b/src/adapters/store_migration/filesystem_migration_fixed_artifact.rs index 3a1ff7fb..f5b9c532 100644 --- a/src/adapters/store_migration/filesystem_migration_fixed_artifact.rs +++ b/src/adapters/store_migration/filesystem_migration_fixed_artifact.rs @@ -7,7 +7,7 @@ use cap_std::fs::{Dir, File}; use super::{format_marker_decoder, migration_intent_format, migration_receipt_format}; use crate::adapters::filesystem_catalog_artifact; use crate::adapters::filesystem_exact_record::{ - self as exact_record, EntryIdentity, ExactRecordError, ExactRecordRefusal, + self as exact_record, EntryIdentity, ExactRecordError, }; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -83,7 +83,7 @@ impl FilesystemMigrationFixedStage { let prefix = expected .get(..end) .filter(|prefix| prefix.len() < expected.len()) - .ok_or_else(|| invalid_data("migration stage prefix is not strict"))?; + .ok_or_else(|| invalid_data(super::FilesystemMigrationRefusal::StagePrefix))?; let mut file = filesystem_catalog_artifact::create_exclusive(root, artifact.stage_name())?; file.write_all(prefix)?; file.flush() @@ -143,7 +143,9 @@ impl FilesystemMigrationFixedStage { if observed == self.identity { Ok(()) } else { - Err(invalid_data("migration stage handle changed identity")) + Err(invalid_data( + super::FilesystemMigrationRefusal::StageIdentityChanged, + )) } } @@ -155,7 +157,7 @@ impl FilesystemMigrationFixedStage { if self.artifact == artifact && self.expected.as_ref() == expected { Ok(()) } else { - Err(invalid_data("migration stage record disagreed")) + Err(invalid_data(super::FilesystemMigrationRefusal::StageRecord)) } } @@ -188,33 +190,23 @@ fn verify_named_record( exact_record::verify_named(root, name, expected, identity).map_err(migration_error) } -/// Maps a shared exact-record failure onto this protocol's refusal messages. +/// Retains exact-record refusals and the filesystem's original operational errors. fn migration_error(error: ExactRecordError) -> io::Error { - match error { - ExactRecordError::Io(source) => source, - ExactRecordError::Refused(refusal) => invalid_data(match refusal { - ExactRecordRefusal::LengthOverflow => "migration fixed-record length exceeded u64", - ExactRecordRefusal::KindOrLength | ExactRecordRefusal::KindLengthOrIdentity => { - "migration fixed-record kind, length, or identity disagreed" - } - ExactRecordRefusal::Bytes | ExactRecordRefusal::TrailingBytes => { - "migration fixed-record bytes disagreed" - } - ExactRecordRefusal::RemainedVisible => "removed migration stage remained visible", - }), - } + error.into_io() } fn require_length(artifact: FilesystemMigrationFixedArtifact, expected: &[u8]) -> io::Result<()> { if expected.len() == artifact.encoded_length() { Ok(()) } else { - Err(invalid_data("migration fixed-record length disagreed")) + Err(invalid_data( + super::FilesystemMigrationRefusal::RecordLength, + )) } } -fn invalid_data(message: &'static str) -> io::Error { - io::Error::new(io::ErrorKind::InvalidData, message) +fn invalid_data(refusal: super::FilesystemMigrationRefusal) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, refusal) } impl FilesystemMigrationFixedStage { diff --git a/src/adapters/store_migration/filesystem_migration_namespace.rs b/src/adapters/store_migration/filesystem_migration_namespace.rs index ca557280..2a847921 100644 --- a/src/adapters/store_migration/filesystem_migration_namespace.rs +++ b/src/adapters/store_migration/filesystem_migration_namespace.rs @@ -81,7 +81,9 @@ pub(super) fn admit_reader_fence(root: &Dir) -> io::Result<()> { let before = exact_membership(root, &BEFORE_READER)?; let after = exact_membership(root, &AFTER_READER)?; if !before && !after { - return Err(ambiguous("reader-fence predecessor namespace disagreed")); + return Err(ambiguous( + super::FilesystemMigrationRefusal::ReaderFencePredecessorNamespace, + )); } let file = if before { filesystem_migration_reader_fence::create(root)? @@ -102,7 +104,7 @@ pub(super) fn admit_namespace_prefix(root: &Dir) -> io::Result<()> { let prefix = admit_directories(root, PREFIX_DIRECTORY_COUNT)?; let (retention, roots, manifests, gc, recovery, dispositions) = prefix .complete() - .ok_or_else(|| ambiguous("namespace prefix admission stopped short"))?; + .ok_or_else(|| ambiguous(super::FilesystemMigrationRefusal::NamespacePrefixIncomplete))?; roots.verify(retention.directory())?; manifests.verify(retention.directory())?; dispositions.verify(recovery.directory())?; @@ -118,7 +120,9 @@ pub(super) fn admit_namespace_prefix(root: &Dir) -> io::Result<()> { #[cfg(feature = "repository-tasks")] pub(super) fn admit_namespace_prefix_partially(root: &Dir, count: usize) -> io::Result<()> { if count > PREFIX_DIRECTORY_COUNT { - return Err(ambiguous("namespace prefix count exceeds the protocol")); + return Err(ambiguous( + super::FilesystemMigrationRefusal::NamespacePrefixCount, + )); } preflight_prefix(root)?; admit_directories(root, count).map(|_prefix| ()) @@ -179,7 +183,8 @@ fn admit_child( parent: Option<&PinnedMigrationDirectory>, name: &'static str, ) -> io::Result { - let parent = parent.ok_or_else(|| ambiguous("namespace prefix parent was not admitted"))?; + let parent = parent + .ok_or_else(|| ambiguous(super::FilesystemMigrationRefusal::NamespaceParentAbsent))?; PinnedMigrationDirectory::admit(parent.directory(), name) } @@ -229,10 +234,14 @@ fn preflight_prefix(root: &Dir) -> io::Result<()> { let recovery = optional_directory(root, RECOVERY)?; let retention_complete = preflight_retention(retention.as_ref())?; if gc.is_some() && !retention_complete { - return Err(ambiguous("gc appeared before the retention prefix")); + return Err(ambiguous( + super::FilesystemMigrationRefusal::GcBeforeRetention, + )); } if recovery.is_some() && gc.is_none() { - return Err(ambiguous("recovery appeared before the gc prefix")); + return Err(ambiguous( + super::FilesystemMigrationRefusal::RecoveryBeforeGc, + )); } if let Some(directory) = gc.as_ref() { require_empty(directory.directory())?; @@ -279,7 +288,9 @@ fn preflight_retention(retention: Option<&PinnedMigrationDirectory>) -> io::Resu let roots = optional_directory(retention.directory(), ROOTS)?; let manifests = optional_directory(retention.directory(), MANIFESTS)?; if manifests.is_some() && roots.is_none() { - return Err(ambiguous("retention manifests appeared before roots")); + return Err(ambiguous( + super::FilesystemMigrationRefusal::ManifestsBeforeRoots, + )); } if let Some(directory) = roots.as_ref() { require_empty(directory.directory())?; diff --git a/src/adapters/store_migration/filesystem_migration_namespace_directory.rs b/src/adapters/store_migration/filesystem_migration_namespace_directory.rs index 297dd829..ac1d3fd9 100644 --- a/src/adapters/store_migration/filesystem_migration_namespace_directory.rs +++ b/src/adapters/store_migration/filesystem_migration_namespace_directory.rs @@ -52,7 +52,9 @@ impl PinnedMigrationDirectory { { Ok(()) } else { - Err(ambiguous("migration directory changed identity")) + Err(ambiguous( + super::FilesystemMigrationRefusal::DirectoryIdentityChanged, + )) } } @@ -79,7 +81,7 @@ pub(super) fn optional_directory( pinned.verify(parent)?; Ok(Some(pinned)) } - Ok(_) => Err(ambiguous("migration namespace entry has the wrong kind")), + Ok(_) => Err(ambiguous(super::FilesystemMigrationRefusal::NamespaceKind)), } } @@ -90,7 +92,7 @@ fn require_same_filesystem(parent: &Dir, child: &Dir) -> io::Result<()> { Ok(()) } else { Err(ambiguous( - "migration namespace crossed the admitted filesystem or mount", + super::FilesystemMigrationRefusal::NamespaceMountChanged, )) } } @@ -100,28 +102,29 @@ pub(super) fn required_directory( name: &'static str, ) -> io::Result { optional_directory(parent, name)? - .ok_or_else(|| ambiguous("required migration namespace directory was absent")) + .ok_or_else(|| ambiguous(super::FilesystemMigrationRefusal::DirectoryAbsent)) } pub(super) fn require_directory(parent: &Dir, name: &str) -> io::Result<()> { if parent.symlink_metadata(name)?.is_dir() { Ok(()) } else { - Err(ambiguous("required migration directory has the wrong kind")) + Err(ambiguous( + super::FilesystemMigrationRefusal::RequiredDirectoryKind, + )) } } pub(super) fn require_regular(parent: &Dir, name: &str, length: Option) -> io::Result<()> { let metadata = parent.symlink_metadata(name)?; - let expected = length - .map(u64::try_from) - .transpose() - .map_err(|_source| ambiguous("required migration file length exceeded u64"))?; + let expected = length.map(u64::try_from).transpose().map_err(|_source| { + ambiguous(super::FilesystemMigrationRefusal::RequiredFileLengthOverflow) + })?; if metadata.is_file() && expected.is_none_or(|expected| metadata.len() == expected) { Ok(()) } else { Err(ambiguous( - "required migration file has the wrong kind or length", + super::FilesystemMigrationRefusal::RequiredFileKindOrLength, )) } } @@ -131,7 +134,9 @@ pub(super) fn require_empty(directory: &Dir) -> io::Result<()> { if entries.next().transpose()?.is_none() { Ok(()) } else { - Err(ambiguous("new migration namespace was not empty")) + Err(ambiguous( + super::FilesystemMigrationRefusal::NamespaceNotEmpty, + )) } } @@ -139,7 +144,9 @@ pub(super) fn require_exact_membership(directory: &Dir, expected: &[&str]) -> io if exact_membership(directory, expected)? { Ok(()) } else { - Err(ambiguous("migration namespace membership disagreed")) + Err(ambiguous( + super::FilesystemMigrationRefusal::NamespaceMembership, + )) } } @@ -164,15 +171,17 @@ pub(super) fn require_allowed_membership(directory: &Dir, allowed: &[&str]) -> i for entry in directory.entries()? { observed = observed .checked_add(1) - .ok_or_else(|| ambiguous("migration namespace count overflowed"))?; + .ok_or_else(|| ambiguous(super::FilesystemMigrationRefusal::NamespaceCountOverflow))?; let name = entry?.file_name(); if observed > allowed.len() || !allowed.iter().any(|candidate| name == *candidate) { - return Err(ambiguous("migration namespace contains an unknown entry")); + return Err(ambiguous( + super::FilesystemMigrationRefusal::UnknownNamespaceEntry, + )); } } Ok(()) } -pub(super) fn ambiguous(message: &'static str) -> io::Error { - io::Error::new(io::ErrorKind::InvalidData, message) +pub(super) fn ambiguous(refusal: super::FilesystemMigrationRefusal) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, refusal) } diff --git a/src/adapters/store_migration/filesystem_migration_reader_fence.rs b/src/adapters/store_migration/filesystem_migration_reader_fence.rs index 7465fb12..23366ed1 100644 --- a/src/adapters/store_migration/filesystem_migration_reader_fence.rs +++ b/src/adapters/store_migration/filesystem_migration_reader_fence.rs @@ -31,7 +31,7 @@ pub(super) fn verify(root: &Dir, file: &File) -> io::Result<()> { Ok(()) } else { Err(ambiguous( - "reader fence kind, length, or identity disagreed", + super::FilesystemMigrationRefusal::ReaderFenceChanged, )) } } diff --git a/src/adapters/store_migration/filesystem_migration_recovery.rs b/src/adapters/store_migration/filesystem_migration_recovery.rs index 801b606d..fa0bccdd 100644 --- a/src/adapters/store_migration/filesystem_migration_recovery.rs +++ b/src/adapters/store_migration/filesystem_migration_recovery.rs @@ -120,10 +120,10 @@ impl StoreMigrationRecoveryStorage for FilesystemStoreMigrationAuthority { exact_record::require_absent(root, artifact.canonical_name()).map_err(refusal)?; let metadata = root.symlink_metadata(artifact.stage_name())?; let complete = u64::try_from(artifact.encoded_length()) - .map_err(|_| invalid("migration stage length exceeded u64"))?; + .map_err(|_| invalid(super::FilesystemMigrationRefusal::StageLengthOverflow))?; if !metadata.is_file() || metadata.len() >= complete { return Err(invalid( - "only an incomplete regular pre-effect stage may be discarded", + super::FilesystemMigrationRefusal::DiscardRequiresIncompleteStage, )); } root.remove_file(artifact.stage_name())?; @@ -146,7 +146,9 @@ fn adopt_artifact( let root = authority.root(); if stage.is_some_and(|bytes| bytes == expected) { if authority.fixed_stage.is_some() { - return Err(invalid("migration residue holds more than one exact stage")); + return Err(invalid( + super::FilesystemMigrationRefusal::MultipleExactStages, + )); } let reopened = FilesystemMigrationFixedStage::reopen_stage(root, artifact, expected)?; authority.fixed_stage = Some(reopened); @@ -164,14 +166,9 @@ fn adopt_artifact( } fn refusal(error: ExactRecordError) -> io::Error { - match error { - ExactRecordError::Io(source) => source, - ExactRecordError::Refused(refusal) => { - io::Error::new(io::ErrorKind::InvalidData, refusal.to_string()) - } - } + error.into_io() } -fn invalid(message: &'static str) -> io::Error { - io::Error::new(io::ErrorKind::InvalidData, message) +fn invalid(refusal: super::FilesystemMigrationRefusal) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, refusal) } diff --git a/src/adapters/store_migration/filesystem_migration_refusal.rs b/src/adapters/store_migration/filesystem_migration_refusal.rs new file mode 100644 index 00000000..f3ecd9c3 --- /dev/null +++ b/src/adapters/store_migration/filesystem_migration_refusal.rs @@ -0,0 +1,135 @@ +//! This module owns migration filesystem protocol-state refusals. + +use std::error::Error; +use std::fmt; + +/// A semantic filesystem protocol refusal, preserved through I/O adapters. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum FilesystemMigrationRefusal { + /// Version-one staging holds evidence that must be recovered before migration. + VersionOneStageRequiresRecovery, + /// A recovery phase was dispatched outside its protocol section. + WrongPhaseSection { + /// The phase that cannot be executed by that section. + observed: super::StoreMigrationPhase, + }, + /// Migration fixed stage was already active. + StageAlreadyActive, + /// Migration fixed record was already published. + RecordAlreadyPublished, + /// Migration fixed stage was not active. + NoActiveStage, + /// A different migration fixed stage was active. + DifferentStageActive, + /// Migration fixed record was not published. + RecordNotPublished, + /// Reader fence kind, length, or identity disagreed. + ReaderFenceChanged, + /// Migration residue bound overflowed. + ResidueBoundOverflow, + /// Reader fence has the wrong kind or length. + ReaderFenceKindOrLength, + /// Migration namespace entry has the wrong kind. + NamespaceKind, + /// Migration directory changed identity. + DirectoryIdentityChanged, + /// Migration namespace crossed the admitted filesystem or mount. + NamespaceMountChanged, + /// Required migration namespace directory was absent. + DirectoryAbsent, + /// Required migration directory has the wrong kind. + RequiredDirectoryKind, + /// Required migration file length exceeded u64. + RequiredFileLengthOverflow, + /// Required migration file has the wrong kind or length. + RequiredFileKindOrLength, + /// New migration namespace was not empty. + NamespaceNotEmpty, + /// Migration namespace membership disagreed. + NamespaceMembership, + /// Migration namespace count overflowed. + NamespaceCountOverflow, + /// Migration namespace contains an unknown entry. + UnknownNamespaceEntry, + /// Reader-fence predecessor namespace disagreed. + ReaderFencePredecessorNamespace, + /// Namespace prefix admission stopped short. + NamespacePrefixIncomplete, + /// Namespace prefix count exceeds the protocol. + NamespacePrefixCount, + /// Namespace prefix parent was not admitted. + NamespaceParentAbsent, + /// Gc appeared before the retention prefix. + GcBeforeRetention, + /// Recovery appeared before the gc prefix. + RecoveryBeforeGc, + /// Retention manifests appeared before roots. + ManifestsBeforeRoots, + /// Migration stage length exceeded u64. + StageLengthOverflow, + /// Only an incomplete regular pre-effect stage may be discarded. + DiscardRequiresIncompleteStage, + /// Migration residue holds more than one exact stage. + MultipleExactStages, + /// Migration stage prefix is not strict. + StagePrefix, + /// Migration stage handle changed identity. + StageIdentityChanged, + /// Migration stage record disagreed. + StageRecord, + /// Migration fixed-record length disagreed. + RecordLength, +} + +impl fmt::Display for FilesystemMigrationRefusal { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::VersionOneStageRequiresRecovery => { + "version-one staging holds a retained stage; recover it before migration" + } + Self::WrongPhaseSection { .. } => "migration phase dispatched to the wrong section", + Self::StageAlreadyActive => "migration fixed stage was already active", + Self::RecordAlreadyPublished => "migration fixed record was already published", + Self::NoActiveStage => "migration fixed stage was not active", + Self::DifferentStageActive => "a different migration fixed stage was active", + Self::RecordNotPublished => "migration fixed record was not published", + Self::ReaderFenceChanged => "reader fence kind, length, or identity disagreed", + Self::ResidueBoundOverflow => "migration residue bound overflowed", + Self::ReaderFenceKindOrLength => "reader fence has the wrong kind or length", + Self::NamespaceKind => "migration namespace entry has the wrong kind", + Self::DirectoryIdentityChanged => "migration directory changed identity", + Self::NamespaceMountChanged => { + "migration namespace crossed the admitted filesystem or mount" + } + Self::DirectoryAbsent => "required migration namespace directory was absent", + Self::RequiredDirectoryKind => "required migration directory has the wrong kind", + Self::RequiredFileLengthOverflow => "required migration file length exceeded u64", + Self::RequiredFileKindOrLength => { + "required migration file has the wrong kind or length" + } + Self::NamespaceNotEmpty => "new migration namespace was not empty", + Self::NamespaceMembership => "migration namespace membership disagreed", + Self::NamespaceCountOverflow => "migration namespace count overflowed", + Self::UnknownNamespaceEntry => "migration namespace contains an unknown entry", + Self::ReaderFencePredecessorNamespace => "reader-fence predecessor namespace disagreed", + Self::NamespacePrefixIncomplete => "namespace prefix admission stopped short", + Self::NamespacePrefixCount => "namespace prefix count exceeds the protocol", + Self::NamespaceParentAbsent => "namespace prefix parent was not admitted", + Self::GcBeforeRetention => "gc appeared before the retention prefix", + Self::RecoveryBeforeGc => "recovery appeared before the gc prefix", + Self::ManifestsBeforeRoots => "retention manifests appeared before roots", + Self::StageLengthOverflow => "migration stage length exceeded u64", + Self::DiscardRequiresIncompleteStage => { + "only an incomplete regular pre-effect stage may be discarded" + } + Self::MultipleExactStages => "migration residue holds more than one exact stage", + Self::StagePrefix => "migration stage prefix is not strict", + Self::StageIdentityChanged => "migration stage handle changed identity", + Self::StageRecord => "migration stage record disagreed", + Self::RecordLength => "migration fixed-record length disagreed", + }) + } +} + +impl Error for FilesystemMigrationRefusal {} diff --git a/src/adapters/store_migration/filesystem_migration_residue.rs b/src/adapters/store_migration/filesystem_migration_residue.rs index 3914b806..648f44c2 100644 --- a/src/adapters/store_migration/filesystem_migration_residue.rs +++ b/src/adapters/store_migration/filesystem_migration_residue.rs @@ -34,11 +34,8 @@ pub(super) fn observe(root: &Dir) -> io::Result { fn bounded_file(root: &Dir, name: &str, length: usize) -> io::Result>> { let bound = length .checked_add(1) - .ok_or_else(|| ambiguous("migration residue bound overflowed"))?; - exact_record::read_bounded_optional(root, name, bound).map_err(|error| match error { - ExactRecordError::Io(source) => source, - ExactRecordError::Refused(_) => ambiguous("migration residue entry has the wrong kind"), - }) + .ok_or_else(|| ambiguous(super::FilesystemMigrationRefusal::ResidueBoundOverflow))?; + exact_record::read_bounded_optional(root, name, bound).map_err(ExactRecordError::into_io) } fn reader_fence(root: &Dir) -> io::Result { @@ -46,7 +43,9 @@ fn reader_fence(root: &Dir) -> io::Result { Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(false), Err(source) => Err(source), Ok(metadata) if metadata.is_file() && metadata.len() == 0 => Ok(true), - Ok(_) => Err(ambiguous("reader fence has the wrong kind or length")), + Ok(_) => Err(ambiguous( + super::FilesystemMigrationRefusal::ReaderFenceKindOrLength, + )), } } @@ -80,6 +79,6 @@ fn optional_directory(parent: &Dir, name: &str) -> io::Result> { Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(None), Err(source) => Err(source), Ok(metadata) if metadata.is_dir() => parent.open_dir_nofollow(name).map(Some), - Ok(_) => Err(ambiguous("migration namespace entry has the wrong kind")), + Ok(_) => Err(ambiguous(super::FilesystemMigrationRefusal::NamespaceKind)), } } diff --git a/src/adapters/store_migration/filesystem_migration_storage.rs b/src/adapters/store_migration/filesystem_migration_storage.rs index b7ff6e4d..1a70b645 100644 --- a/src/adapters/store_migration/filesystem_migration_storage.rs +++ b/src/adapters/store_migration/filesystem_migration_storage.rs @@ -134,7 +134,9 @@ fn write_stage( expected: &[u8], ) -> io::Result<()> { if authority.fixed_stage.is_some() { - return Err(stage_state("migration fixed stage was already active")); + return Err(stage_state( + super::FilesystemMigrationRefusal::StageAlreadyActive, + )); } let stage = FilesystemMigrationFixedStage::create(authority.root(), artifact, expected)?; authority.fixed_stage = Some(stage); @@ -161,15 +163,19 @@ fn remove_stage( artifact: FixedArtifact, ) -> io::Result<()> { if published_stage(authority, artifact).is_some() { - return Err(stage_state("migration fixed record was already published")); + return Err(stage_state( + super::FilesystemMigrationRefusal::RecordAlreadyPublished, + )); } let stage = authority .fixed_stage .take() - .ok_or_else(|| stage_state("migration fixed stage was not active"))?; + .ok_or_else(|| stage_state(super::FilesystemMigrationRefusal::NoActiveStage))?; if stage.artifact() != artifact { authority.fixed_stage = Some(stage); - return Err(stage_state("a different migration fixed stage was active")); + return Err(stage_state( + super::FilesystemMigrationRefusal::DifferentStageActive, + )); } let published = stage.remove(authority.root())?; match artifact { @@ -187,11 +193,13 @@ fn active_stage( let stage = authority .fixed_stage .as_ref() - .ok_or_else(|| stage_state("migration fixed stage was not active"))?; + .ok_or_else(|| stage_state(super::FilesystemMigrationRefusal::NoActiveStage))?; if stage.artifact() == artifact { Ok(stage) } else { - Err(stage_state("a different migration fixed stage was active")) + Err(stage_state( + super::FilesystemMigrationRefusal::DifferentStageActive, + )) } } @@ -200,7 +208,7 @@ fn verify_published( artifact: FixedArtifact, ) -> io::Result<()> { published_stage(authority, artifact) - .ok_or_else(|| stage_state("migration fixed record was not published"))? + .ok_or_else(|| stage_state(super::FilesystemMigrationRefusal::RecordNotPublished))? .verify_canonical(authority.root()) } @@ -237,6 +245,6 @@ fn synchronize_root(authority: &FilesystemStoreMigrationAuthority) -> io::Result filesystem_catalog_artifact::synchronize_directory(authority.root()) } -fn stage_state(message: &'static str) -> io::Error { - io::Error::new(io::ErrorKind::InvalidData, message) +fn stage_state(refusal: super::FilesystemMigrationRefusal) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, refusal) } diff --git a/src/adapters/store_migration/filesystem_migration_storage_tests.rs b/src/adapters/store_migration/filesystem_migration_storage_tests.rs index b0cd6560..84cd721a 100644 --- a/src/adapters/store_migration/filesystem_migration_storage_tests.rs +++ b/src/adapters/store_migration/filesystem_migration_storage_tests.rs @@ -63,6 +63,17 @@ fn byte_equal_substituted_canonical_intent_is_refused() -> Result<(), Box()), + Some( + crate::adapters::filesystem_exact_record::ExactRecordError::Refused( + crate::adapters::filesystem_exact_record::ExactRecordRefusal::KindLengthOrIdentity + ) + ) + )); drop(authority); sandbox.remove()?; Ok(()) diff --git a/src/adapters/store_migration/migration_resumption.rs b/src/adapters/store_migration/migration_resumption.rs index 8e103a53..e09ac3f2 100644 --- a/src/adapters/store_migration/migration_resumption.rs +++ b/src/adapters/store_migration/migration_resumption.rs @@ -157,6 +157,6 @@ fn receipt_phase( fn wrong_section(phase: StoreMigrationPhase) -> io::Error { io::Error::new( io::ErrorKind::InvalidInput, - format!("migration phase {phase} dispatched to the wrong section"), + super::FilesystemMigrationRefusal::WrongPhaseSection { observed: phase }, ) } diff --git a/src/adapters/sync_capable_directory.rs b/src/adapters/sync_capable_directory.rs index 0951a11c..75413f73 100644 --- a/src/adapters/sync_capable_directory.rs +++ b/src/adapters/sync_capable_directory.rs @@ -18,7 +18,7 @@ pub(super) fn open(parent: &Dir, name: &str) -> io::Result { if !file.metadata()?.is_dir() { return Err(io::Error::new( io::ErrorKind::NotADirectory, - "sync-capable target is not a directory", + super::FilesystemOperationRefusal::DirectoryRequired, )); } Ok(Dir::from_std_file(file.into_std())) diff --git a/src/lib.rs b/src/lib.rs index 427b2f04..4150b2ac 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -66,6 +66,12 @@ mod retention; mod store; mod verification; +pub use adapters::CompactionRecoveryRefusal; +pub use adapters::FilesystemOperationRefusal; +pub use adapters::{ + FilesystemGcRefusal, FilesystemMigrationRefusal, FilesystemRetentionStageRefusal, +}; + #[cfg(feature = "repository-tasks")] #[doc(hidden)] pub use adapters::RepositoryInitializationStorage; @@ -172,25 +178,26 @@ pub use adapters::{ FilesystemRetentionAuthorityError, FilesystemRetentionDispositionError, FilesystemRetentionPublicationAuthority, FilesystemRetentionRecoveryError, FilesystemRetentionSnapshot, FilesystemRetentionSnapshotError, ObservedRetentionState, - PreparedRetentionPublication, ReaderAttemptLimit, ReaderFence, RecoveryDispositionAmbiguity, - RecoveryDispositionError, RecoveryDispositionExecutionReceipt, RecoveryDispositionPhase, - RecoveryDispositionPlan, RecoveryDispositionRefusal, RecoveryDispositionRequest, - RecoveryDispositionStorage, RecoveryDispositionTarget, RetentionAuthorityDirectory, - RetentionClosureVerificationError, RetentionCurrentStateRefusal, RetentionFixedStage, - RetentionHeadDecodeError, RetentionHeadStageAssessment, RetentionManifestDecodeError, - RetentionManifestEncodeError, RetentionManifestStageAssessment, RetentionNamespaceAdmission, - RetentionPool, RetentionPoolEntryObservation, RetentionPoolObservations, - RetentionPublicationError, RetentionPublicationOutcome, RetentionPublicationPhase, - RetentionPublicationPreparation, RetentionPublicationPreparationError, - RetentionPublicationReceipt, RetentionPublicationStorage, RetentionRecoveryError, - RetentionRecoveryEvidence, RetentionRecoveryOutcome, RetentionRecoveryPlan, - RetentionRecoveryReceipt, RetentionRecoveryRefusal, RetentionRecoveryStep, - RetentionRecoveryStorage, RetentionRootDecodeError, RetentionRootEncodeError, - RetentionRootStageAssessment, RetentionStageAssessment, RetentionStageAssessments, - RetentionTransitionDisposition, RetentionTransitionError, RetentionTransitionPreflight, - RetentionTransitionPreflightError, RetentionTransitionReadiness, RetentionViewCoordinates, - RetentionViewError, RetentionViewSource, VerifiedRetentionClosure, assess_head_stage, - assess_manifest_stage, assess_root_stage, collect_retention_view, execute_recovery_disposition, + PreparedRetentionPublication, ReaderAttemptLimit, ReaderFence, ReaderFenceKind, + ReaderFenceRefusal, RecoveryDispositionAmbiguity, RecoveryDispositionError, + RecoveryDispositionExecutionReceipt, RecoveryDispositionPhase, RecoveryDispositionPlan, + RecoveryDispositionRefusal, RecoveryDispositionRequest, RecoveryDispositionStorage, + RecoveryDispositionTarget, RetentionAuthorityDirectory, RetentionClosureVerificationError, + RetentionCurrentStateRefusal, RetentionFixedStage, RetentionHeadDecodeError, + RetentionHeadStageAssessment, RetentionManifestDecodeError, RetentionManifestEncodeError, + RetentionManifestStageAssessment, RetentionNamespaceAdmission, RetentionPool, + RetentionPoolEntryObservation, RetentionPoolObservations, RetentionPublicationError, + RetentionPublicationOutcome, RetentionPublicationPhase, RetentionPublicationPreparation, + RetentionPublicationPreparationError, RetentionPublicationReceipt, RetentionPublicationStorage, + RetentionRecoveryError, RetentionRecoveryEvidence, RetentionRecoveryOutcome, + RetentionRecoveryPlan, RetentionRecoveryReceipt, RetentionRecoveryRefusal, + RetentionRecoveryStep, RetentionRecoveryStorage, RetentionRootDecodeError, + RetentionRootEncodeError, RetentionRootStageAssessment, RetentionSnapshotRefusal, + RetentionStageAssessment, RetentionStageAssessments, RetentionTransitionDisposition, + RetentionTransitionError, RetentionTransitionPreflight, RetentionTransitionPreflightError, + RetentionTransitionReadiness, RetentionViewCoordinates, RetentionViewError, + RetentionViewSource, VerifiedRetentionClosure, assess_head_stage, assess_manifest_stage, + assess_root_stage, collect_retention_view, execute_recovery_disposition, execute_retention_publication, execute_retention_recovery, plan_recovery_disposition, plan_retention_recovery, plan_retention_transition, preflight_retention_transition, prepare_retention_publication, resume_recovery_disposition, verify_retention_closure, diff --git a/src/reference/chunk_reader.rs b/src/reference/chunk_reader.rs index df76c498..521829ad 100644 --- a/src/reference/chunk_reader.rs +++ b/src/reference/chunk_reader.rs @@ -107,7 +107,7 @@ const fn source_error(refusal: ChunkVerificationError) -> TransferSourceError { } fn refused(refusal: ChunkVerificationError) -> io::Error { - io::Error::new(io::ErrorKind::InvalidData, format!("{refusal:?}")) + io::Error::new(io::ErrorKind::InvalidData, refusal) } impl Read for ChunkReader<'_, S> { @@ -237,6 +237,11 @@ mod tests { return Err("expected a chunk refusal".into()); }; assert_eq!(refusal.kind(), std::io::ErrorKind::InvalidData); + assert!(matches!( + refusal.get_ref().and_then(|source| source.downcast_ref::()), + Some(ChunkVerificationError::IdentityMismatch { index: 1, expected, observed, .. }) + if *expected == second && expected != observed + )); let first_length = usize::try_from( layout .entries() diff --git a/src/reference/chunk_verification.rs b/src/reference/chunk_verification.rs index ef8cc563..223d2375 100644 --- a/src/reference/chunk_verification.rs +++ b/src/reference/chunk_verification.rs @@ -1,5 +1,8 @@ //! Exact reference-store chunk lookup and authentication. +use std::error::Error; +use std::fmt; + use crate::{ChunkHashError, ChunkId, LayoutEntry, LayoutId}; /// One admitted view's exact chunk lookup: the reference store's in-memory @@ -70,6 +73,41 @@ pub(super) enum ChunkVerificationError { }, } +impl fmt::Display for ChunkVerificationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Missing { + index, requested, .. + } => { + write!(formatter, "chunk {index} is absent: {requested:?}") + } + Self::Hash { index, source, .. } => { + write!(formatter, "chunk {index} hashing refused: {source}") + } + Self::IdentityMismatch { + index, + expected, + observed, + .. + } => { + write!( + formatter, + "chunk {index} identity differs: expected {expected:?}, observed {observed:?}" + ) + } + } + } +} + +impl Error for ChunkVerificationError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Hash { source, .. } => Some(source), + Self::Missing { .. } | Self::IdentityMismatch { .. } => None, + } + } +} + /// Fetches an already-authenticated immutable chunk for emission. /// /// The verification pass hashed every selected chunk before the first byte diff --git a/src/store/port_laws.rs b/src/store/port_laws.rs index 00a3d489..fe3ad50c 100644 --- a/src/store/port_laws.rs +++ b/src/store/port_laws.rs @@ -21,14 +21,10 @@ pub(crate) fn reconstructs_exactly( ) -> Result<(), Box> { assert!(view.contains_blob(target)); let mut output = Vec::new(); - let receipt = view - .reconstruct(target, &mut output) - .map_err(|error| error.to_string())?; + let receipt = view.reconstruct(target, &mut output)?; assert_eq!(output.as_slice(), expected); let mut exact = Vec::new(); - let exact_receipt = view - .reconstruct_layout(layout_id, &mut exact) - .map_err(|error| error.to_string())?; + let exact_receipt = view.reconstruct_layout(layout_id, &mut exact)?; assert_eq!(exact.as_slice(), expected); assert_eq!(exact_receipt, receipt); Ok(()) @@ -45,9 +41,7 @@ pub(crate) fn ranges_exactly( for (offset, length) in ranges { let requested = ByteRange::new(ByteOffset::new(*offset), ByteLength::new(*length))?; let mut output = Vec::new(); - let _receipt = view - .read_range(target, requested, &mut output) - .map_err(|error| error.to_string())?; + let _receipt = view.read_range(target, requested, &mut output)?; let start = usize::try_from(*offset)?; let end = usize::try_from(offset.saturating_add(*length))?; assert_eq!(Some(output.as_slice()), expected.get(start..end)); diff --git a/tests/typed_refusal_source_contract.rs b/tests/typed_refusal_source_contract.rs new file mode 100644 index 00000000..154cf402 --- /dev/null +++ b/tests/typed_refusal_source_contract.rs @@ -0,0 +1,74 @@ +//! Production I/O adapters retain typed payloads instead of constructing textual failures. + +use std::error::Error; +use std::fs; +use std::path::Path; + +#[test] +fn production_io_failures_never_start_with_a_text_payload() -> Result<(), Box> { + let mut pending = vec![Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; + while let Some(directory) = pending.pop() { + for entry in fs::read_dir(directory)? { + let path = entry?.path(); + if path.is_dir() { + pending.push(path); + } else if is_production_source(&path) { + assert_typed_payloads(&path, &fs::read_to_string(&path)?); + } + } + } + Ok(()) +} + +fn is_production_source(path: &Path) -> bool { + path.extension().is_some_and(|extension| extension == "rs") + && path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| !name.contains("test")) +} + +#[expect( + clippy::literal_string_with_formatting_args, + reason = "the guard matches Rust source syntax, not a formatted diagnostic" +)] +fn assert_typed_payloads(path: &Path, source: &str) { + let production = source + .split("\n#[cfg(test)]\nmod tests") + .next() + .unwrap_or_default(); + let compact: String = production + .chars() + .filter(|character| !character.is_whitespace()) + .collect(); + for call in compact.split("io::Error::new(").skip(1) { + if let Some((_kind, payload)) = call.split_once(',') { + assert!( + !is_text(payload), + "{} constructs an I/O failure from text", + path.display() + ); + } + } + for payload in compact.split("io::Error::other(").skip(1) { + assert!( + !is_text(payload), + "{} constructs an I/O failure from text", + path.display() + ); + } + assert!( + !compact.contains("refusal.to_string()"), + "{} erases a refusal source", + path.display() + ); + assert!( + !compact.contains("format!(\"{refusal:?}\")"), + "{} erases a refusal source", + path.display() + ); +} + +fn is_text(payload: &str) -> bool { + payload.starts_with('"') || payload.starts_with("format!(") +}