From b0469e05a50fdc17d8d58a86bd34e772c91a3c22 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 9 Aug 2026 20:38:40 -0700 Subject: [PATCH 1/3] docs: define authenticated reconstruction contract --- README.md | 4 + .../authenticated-reconstruction/README.md | 239 ++++++++++++++++++ 2 files changed, 243 insertions(+) create mode 100644 docs/invariants/authenticated-reconstruction/README.md diff --git a/README.md b/README.md index 3fc5302..e50aadc 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,10 @@ > For a given content identity, Keep must return exactly the bytes named by > that identity—or refuse. +The [authenticated reconstruction contract](docs/invariants/authenticated-reconstruction/README.md) +defines the proof scopes, output-failure rule, receipt posture, and precise +limits of that promise. + Keep is a standalone Rust library for durable, content-addressed storage. It is intended to provide streaming ingestion, content-defined chunking, physical deduplication, exact range reads, explicit retention, integrity verification, diff --git a/docs/invariants/authenticated-reconstruction/README.md b/docs/invariants/authenticated-reconstruction/README.md new file mode 100644 index 0000000..2a582e0 --- /dev/null +++ b/docs/invariants/authenticated-reconstruction/README.md @@ -0,0 +1,239 @@ +# Authenticated Reconstruction Contract + +**Status:** Normative for every Keep operation that claims authenticated +reconstruction. The public non-durable `ReferenceStore` implements the +complete-object and exact-range forms. A consolidated durable logical-read +surface is not yet implemented. + +## Contract + +For a requested content identity, Keep either: + +1. establishes the proof scope named by the operation; +2. emits exactly the bytes supported by that proof; +3. returns a receipt stating the exact proposition established; + +or it refuses precisely. + +Keep never substitutes different content, promotes a narrower proof into a +broader one, infers truth from physical existence, or silently repairs +ambiguous state. + +```text +typed content coordinate ++ admitted immutable evidence ++ declared proof scope ++ caller-owned output +──────────────────────────── +authenticated bytes + receipt + or typed refusal +``` + +This contract refines Keep's core law: + +> For a given content identity, Keep must return exactly the bytes named by +> that identity—or refuse. + +## Meaning of authenticated + +In this contract, **authenticated** means that Keep established the emitted +bytes against the requested Keep content coordinate using the admitted +identity law and the evidence required by the operation's proof scope. + +Authentication here does not establish: + +- authorship or provenance; +- application meaning; +- trustworthiness of the content; +- caller authorization; +- causal or publication authority outside Keep; +- durability beyond the evidence named by the receipt. + +Those propositions require their own owners and evidence. + +## Coordinates and evidence + +A `BlobId` names one exact finite logical byte sequence. It is independent of +chunking, layouts, representations, physical locations, and retention. Parsing +a `BlobId` proves only that the coordinate is canonical and supported. It does +not prove presence or retention. + +A `LayoutId` names one canonical reconstruction plan. One `BlobId` may have +more than one admitted layout. Every admitted layout binds exactly one target +`BlobId`. + +Physical paths, file names, offsets, inode values, object keys, and successful +raw reads are evidence inputs, not stable content identity. They cannot +authorize output without the required identity and structural verification. + +The following claims remain distinct: + +```text +canonical coordinate + ≠ content present + ≠ content published + ≠ content retained + ≠ content durable + ≠ content reconstructible from this admitted view +``` + +## Complete-object reconstruction + +A successful complete-object reconstruction proves that: + +- every selected chunk matched its `ChunkId`; +- the admitted storage-profile boundaries matched the layout; +- the complete reconstructed sequence matched the requested `BlobId`; +- the exact byte count reported by the receipt was written. + +The current public reference shape is: + +```rust +fn reconstruct( + &self, + target: BlobId, + output: &mut W, +) -> Result; +``` + +When more than one committed layout names the requested blob and the caller +does not request an exact layout, `ReferenceStore` deterministically selects +the lowest canonical `LayoutId`. + +When the caller requests an exact `LayoutId`, Keep must use that layout or +refuse. It must not fall back to another layout, even if another layout could +lawfully reconstruct the same `BlobId`. + +Different bytes or a different target `BlobId` are never lawful substitutes. + +## Exact-range reconstruction + +An exact-range operation has a deliberately narrower proof scope. Its receipt +proves that the requested bytes came from completely authenticated overlapping +chunks under the admitted layout. + +It does not prove that Keep authenticated: + +- unrequested chunks; +- the complete logical blob; +- every storage-profile boundary. + +A `RangeReadReceipt` must never satisfy an API that requires a complete-object +`ReconstructionReceipt`. Complete and range operations remain separate public +surfaces; an optional range parameter must not erase the proof distinction. + +## Output visibility and failure + +The generic reconstruction API accepts an ordinary caller-owned `Write` sink. +An ordinary sink is not transactional. It may fail after accepting a prefix, +and an emission-time storage failure may occur after a prefix has been +written. + +Therefore: + +> A successful receipt authenticates the complete emitted sequence. On +> failure, any bytes already written are uncommitted and must not be consumed +> as authenticated output. + +Callers that require atomic visibility must provide a quarantined or +transactional sink and reveal or promote its bytes only after validating the +complete success receipt. Keep does not claim that an unsuccessful call left +an arbitrary `Write` untouched. + +The current `ReferenceStore` verifies the complete realization before its +first output write, then reverifies each immutable chunk immediately before +emission. This prevents known unauthenticated content from being emitted; it +does not make the caller's sink atomic. + +## Decisions, refusals, and operation failures + +A typed error does not automatically constitute evidence about content truth. +Consumers must distinguish at least: + +- authenticated success; +- an evidenced refusal supported by a complete admitted view; +- an operational failure from which no content conclusion follows. + +For example, absence can be evidenced only when the admitted view and its +indexes are complete enough to prove non-membership. A timeout, unreadable +catalog, exhausted resource limit, cancellation, or unavailable capability +does not prove absence. + +Corruption evidence establishes only the exact integrity proposition its +evidence supports. An unreadable or internally inconsistent view may prevent +both a positive reconstruction claim and a negative absence claim. + +The current `ReferenceStore` exposes boundary-specific Rust errors rather than +a persisted refusal-receipt format. Any future durable refusal receipt must +bind enough coordinates to replay its proposition, including: + +- the requested `BlobId`; +- the admitted immutable view or generation; +- an exact requested `LayoutId`, when present; +- refusal classification; +- proof stage or evidence coordinate; +- contract version. + +## Receipt posture + +A reconstruction receipt is, by default, an ephemeral statement about one +completed operation. It is not automatically a portable, self-contained +cryptographic proof. + +A receipt may support later revalidation only when all evidence it names +remains available and admitted under the same contract. A durable receipt must +name the immutable store view or generation against which the result was +established. Retaining a receipt without retaining its supporting evidence +does not preserve the original proof. + +Current `ReconstructionReceipt` values bind: + +- target `BlobId`; +- selected `LayoutId`; +- exact authenticated byte count written. + +Current `RangeReadReceipt` values additionally bind the requested half-open +range and explicitly carry the narrower range proof posture. + +## Durable reconstruction requirement + +A future operation claiming durable logical reconstruction must additionally: + +- bind reads to one admitted immutable snapshot or catalog generation; +- prevent required supporting evidence from being collected during the read; +- verify the retained closure required by its declared proof scope; +- resolve and authenticate exact immutable records; +- preserve the selected view while successor generations publish; +- return a receipt naming that view; +- separate evidenced refusal from operational failure; +- preserve the output-visibility rule above. + +The current durable segment, catalog, publication, retention, and recovery +surfaces do not yet form this consolidated high-level `BlobId`-to-writer +contract. Their existence must not be described as an implemented durable +logical reconstruction API. + +## Current public evidence + +The non-durable `ReferenceStore` is the executable oracle for this contract. +Its committed state is process memory; process death loses it all. + +Evidence anchors: + +- [`ReferenceStore` architecture](../../architecture/reference-store/README.md) +- [`ReferenceStore` rationale](../../architecture/reference-store/rationale.md) +- [exact logical byte identity](../../adr/0001-exact-logical-byte-identity.md) +- [identity and physical-storage separation](../../adr/0002-separate-identity-from-physical-storage.md) +- [`ReferenceStore` contract tests](../../../tests/reference_store_contract.rs) +- [reconstruction implementation](../../../src/reference/reconstruction.rs) +- [range-read implementation](../../../src/reference/range_read.rs) +- [reconstruction receipt](../../../src/reference/reconstruction_receipt.rs) +- [range-read receipt](../../../src/reference/range_read_receipt.rs) + +## Consumer rule + +Consumers may wrap Keep in application-specific ports, transactional output, +identity bindings, and causal workflows. Those adapters may strengthen output +visibility or attach additional meaning. They must not weaken Keep's proof +scope, treat an operational failure as content evidence, or import +application-specific semantics into Keep core. From 2b87899853b61d2f616f98a33b3d45657af3f621 Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 9 Aug 2026 20:49:16 -0700 Subject: [PATCH 2/3] docs: distinguish reconstruction failures --- docs/invariants/authenticated-reconstruction/README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/invariants/authenticated-reconstruction/README.md b/docs/invariants/authenticated-reconstruction/README.md index 2a582e0..55618e2 100644 --- a/docs/invariants/authenticated-reconstruction/README.md +++ b/docs/invariants/authenticated-reconstruction/README.md @@ -13,7 +13,8 @@ For a requested content identity, Keep either: 2. emits exactly the bytes supported by that proof; 3. returns a receipt stating the exact proposition established; -or it refuses precisely. +or it returns a precise, evidenced content-related refusal; +or it returns an operational failure that makes no claim about content truth. Keep never substitutes different content, promotes a narrower proof into a broader one, infers truth from physical existence, or silently repairs @@ -26,7 +27,8 @@ typed content coordinate + caller-owned output ──────────────────────────── authenticated bytes + receipt - or typed refusal + or evidenced refusal + or operational failure ``` This contract refines Keep's core law: @@ -200,7 +202,8 @@ range and explicitly carry the narrower range proof posture. A future operation claiming durable logical reconstruction must additionally: - bind reads to one admitted immutable snapshot or catalog generation; -- prevent required supporting evidence from being collected during the read; +- prevent required supporting evidence from being garbage-collected, deleted, + or otherwise invalidated during the read; - verify the retained closure required by its declared proof scope; - resolve and authenticate exact immutable records; - preserve the selected view while successor generations publish; From 3bf7b9179db41e90620e6d1875c2d40222a2330b Mon Sep 17 00:00:00 2001 From: James Ross Date: Sun, 9 Aug 2026 21:01:53 -0700 Subject: [PATCH 3/3] docs: substantiate reconstruction contract --- .../authenticated-reconstruction/README.md | 24 +++-- .../authenticated-reconstruction/rationale.md | 91 +++++++++++++++++++ .../requirements.md | 18 ++++ tests/range_read_failures.rs | 43 ++++++++- tests/streaming_cas/refusal_laws.rs | 35 ++++++- tests/support/byte_writers.rs | 53 +++++++++++ tests/support/mod.rs | 4 +- 7 files changed, 259 insertions(+), 9 deletions(-) create mode 100644 docs/invariants/authenticated-reconstruction/rationale.md create mode 100644 docs/invariants/authenticated-reconstruction/requirements.md diff --git a/docs/invariants/authenticated-reconstruction/README.md b/docs/invariants/authenticated-reconstruction/README.md index 55618e2..79910b0 100644 --- a/docs/invariants/authenticated-reconstruction/README.md +++ b/docs/invariants/authenticated-reconstruction/README.md @@ -5,6 +5,10 @@ reconstruction. The public non-durable `ReferenceStore` implements the complete-object and exact-range forms. A consolidated durable logical-read surface is not yet implemented. +The [rationale](rationale.md) records the governed decisions and rejected +alternatives. The [requirement ledger](requirements.md) maps each law to its +oracle and executable evidence or names the remaining gap. + ## Contract For a requested content identity, Keep either: @@ -112,13 +116,16 @@ Different bytes or a different target `BlobId` are never lawful substitutes. An exact-range operation has a deliberately narrower proof scope. Its receipt proves that the requested bytes came from completely authenticated overlapping -chunks under the admitted layout. +chunks under a layout-to-target binding admitted by the selected store view. +Structural admission of caller-supplied layout fields alone is insufficient. +The current caller-supplied entry points calculate the canonical `LayoutId` +and resolve that exact committed layout before planning or output. It does not prove that Keep authenticated: - unrequested chunks; - the complete logical blob; -- every storage-profile boundary. +- any storage-profile boundary. A `RangeReadReceipt` must never satisfy an API that requires a complete-object `ReconstructionReceipt`. Complete and range operations remain separate public @@ -211,10 +218,11 @@ A future operation claiming durable logical reconstruction must additionally: - separate evidenced refusal from operational failure; - preserve the output-visibility rule above. -The current durable segment, catalog, publication, retention, and recovery -surfaces do not yet form this consolidated high-level `BlobId`-to-writer -contract. Their existence must not be described as an implemented durable -logical reconstruction API. +The current durable segment, catalog, publication, and recovery surfaces do +not yet form this consolidated high-level `BlobId`-to-writer contract. +Retention remains planned; no current retention surface protects the evidence +closure required by this operation. These lower-level surfaces must not be +described as an implemented durable logical reconstruction API. ## Current public evidence @@ -223,6 +231,8 @@ Its committed state is process memory; process death loses it all. Evidence anchors: +- [authenticated-reconstruction requirements](requirements.md) +- [authenticated-reconstruction rationale](rationale.md) - [`ReferenceStore` architecture](../../architecture/reference-store/README.md) - [`ReferenceStore` rationale](../../architecture/reference-store/rationale.md) - [exact logical byte identity](../../adr/0001-exact-logical-byte-identity.md) @@ -230,6 +240,8 @@ Evidence anchors: - [`ReferenceStore` contract tests](../../../tests/reference_store_contract.rs) - [reconstruction implementation](../../../src/reference/reconstruction.rs) - [range-read implementation](../../../src/reference/range_read.rs) +- [whole-object refusal laws](../../../tests/streaming_cas/refusal_laws.rs) +- [range-read refusal laws](../../../tests/range_read_failures.rs) - [reconstruction receipt](../../../src/reference/reconstruction_receipt.rs) - [range-read receipt](../../../src/reference/range_read_receipt.rs) diff --git a/docs/invariants/authenticated-reconstruction/rationale.md b/docs/invariants/authenticated-reconstruction/rationale.md new file mode 100644 index 0000000..225a257 --- /dev/null +++ b/docs/invariants/authenticated-reconstruction/rationale.md @@ -0,0 +1,91 @@ +# Authenticated Reconstruction Rationale + +## Decision + +Keep names authenticated reconstruction as one proof-scoped operation rather +than a generic blob lookup. A successful operation emits exact bytes and +returns a receipt for the proposition it established. An evidenced refusal +supports a narrower content proposition. An operational failure supports no +content conclusion. + +Complete-object and exact-range operations remain separate. Complete-object +reconstruction verifies every chunk, the registered storage-profile +boundaries, and the complete `BlobId`. Exact-range reconstruction verifies the +complete identities of overlapping chunks under a layout-to-target binding +admitted by the selected store view. It verifies neither the complete blob nor +any storage-profile boundary. + +The generic output boundary remains an ordinary caller-owned `Write`. A +successful receipt authenticates the complete emitted sequence. Failure may +leave an untrusted prefix, so consumers that require atomic visibility must +quarantine output and publish it transactionally after receipt validation. + +Current `ReferenceStore` behavior is the executable oracle for non-durable +complete-object and range forms. It is not evidence that Keep has a durable +logical reconstruction API. A future durable form must pin one immutable view, +retain its complete supporting evidence, and bind the view into its result. + +## Governed surfaces + +This decision governs: + +- logical identity versus physical realization; +- complete-object and exact-range proof scopes; +- receipt and refusal meaning; +- output visibility after failure; +- layout selection and committed layout-to-target binding; +- the future durable read aperture and evidence-retention obligation; and +- the public integration boundary available to consumers. + +It does not govern Echo semantics, causal authority, application retry law, or +cross-store publication. + +## Alternatives rejected + +### Return `Option>` + +This collapses absence, corruption, unavailable evidence, and operational +failure. It also requires hidden whole-object materialization and cannot carry +proof scope or a receipt. + +### Use one read operation with an optional range + +An optional range makes it easy to pass a range receipt where complete-object +proof is required. Separate operations and receipt types keep the narrower +proof scope visible. + +### Trust any structurally admitted caller layout for range reads + +Structural validation proves offsets, lengths, and canonical layout shape. It +does not prove that the target `BlobId` names the listed chunks. Range receipts +therefore require the exact layout-to-target binding admitted by the selected +store view. + +### Treat every error as an evidenced refusal + +Writer failure, cancellation, resource exhaustion, and unreadable evidence do +not establish a proposition about content. Converting them into refusals would +let infrastructure weather become false storage truth. + +### Promise atomic output from an ordinary `Write` + +An ordinary writer can accept a prefix and fail. Keep cannot roll back an +arbitrary external sink. Transactional visibility belongs to a consumer or +adapter that owns a quarantine and commit protocol. + +### Treat a receipt as a portable durable proof + +Current receipts state what one completed process established. They do not +carry all supporting evidence and remain replayable only while named evidence +is retained under the same contract. + +## Consequences + +- Consumers must preserve proof scope and outcome class. +- Range receipts cannot satisfy complete-object requirements. +- Caller-supplied range layouts must resolve through an admitted store view. +- A failure after output began returns no success receipt; accepted bytes + remain untrusted. +- Durable integration remains blocked on a pinned-view consumer capability and + evidence retention. +- Application-specific meaning remains outside Keep core. diff --git a/docs/invariants/authenticated-reconstruction/requirements.md b/docs/invariants/authenticated-reconstruction/requirements.md new file mode 100644 index 0000000..8914825 --- /dev/null +++ b/docs/invariants/authenticated-reconstruction/requirements.md @@ -0,0 +1,18 @@ +# Authenticated Reconstruction Requirements + +This ledger maps the authenticated reconstruction contract to stable laws, +oracles, evidence classes, and concrete witnesses. “Planned” names a required +gap, not implementation evidence. + +| ID | Exact law | Oracle | Evidence type | Status | Concrete evidence | +| --- | --- | --- | --- | --- | --- | +| `KEEP-RECONSTRUCT-001` | A complete-object receipt is returned only after every selected chunk, registered profile boundary, complete `BlobId`, and emitted length verify. | `ReferenceStore` complete reconstruction model | Public API integration, corruption, and mutation tests | Implemented | `tests/streaming_cas/reconstruction_laws.rs`, `tests/streaming_cas/refusal_laws.rs` | +| `KEEP-RECONSTRUCT-002` | Exact-layout reconstruction uses the requested committed `LayoutId` or refuses without substituting another layout. | Ordered committed-layout model | Public API integration tests | Implemented | `tests/streaming_cas/reconstruction_laws.rs` | +| `KEEP-RECONSTRUCT-003` | A range receipt proves only requested bytes from authenticated overlapping chunks; it proves neither the complete blob nor any storage-profile boundary. | Minimal-overlap range model | Unit, property, and public API integration tests | Implemented | `src/reference/range_read_tests.rs`, `tests/range_read.rs`, `tests/range_read_properties.rs` | +| `KEEP-RECONSTRUCT-004` | A range receipt names only a layout-to-target binding admitted by the selected store view. | Forged same-length target-layout fixture | Public API corruption test | Implemented | `tests/range_read_entrypoints.rs` | +| `KEEP-RECONSTRUCT-005` | Success authenticates the complete emitted sequence; failure returns no success receipt and reports the exact accepted prefix, which remains untrusted. | Deterministic prefix-then-fail writer | Public API failure tests | Implemented | `tests/streaming_cas/refusal_laws.rs`, `tests/range_read_failures.rs` | +| `KEEP-RECONSTRUCT-006` | Authenticated success, evidenced content refusal, and operational failure remain distinct outcomes; operational failure supports no content conclusion. | Typed outcome classification | Public API integration tests and contract inspection | Implemented for `ReferenceStore`; durable refusal receipts planned | `tests/streaming_cas/refusal_laws.rs`, `tests/range_read_failures.rs`; [Keep #22](https://github.com/flyingrobots/keep/issues/22) | +| `KEEP-RECONSTRUCT-007` | Whole-object and range receipts bind target, exact layout, proof scope, and exact emitted coordinates without granting retention or application authority. | Receipt type inspection | Public API contract tests | Implemented | `src/reference/reconstruction_receipt.rs`, `src/reference/range_read_receipt.rs`, `tests/range_read_contract.rs` | +| `KEEP-RECONSTRUCT-008` | Automatic layout choice is deterministic; an exact requested layout never falls back. | Canonically ordered layout set | Unit and public API integration tests | Implemented | `src/reference/store_tests.rs`, `tests/streaming_cas/reconstruction_laws.rs` | +| `KEEP-RECONSTRUCT-009` | A durable read pins one immutable view and prevents required evidence from being garbage-collected, deleted, or invalidated through completion. | Pinned-generation and retained-closure model | Recovery, concurrency, corruption, and crash-injection tests | Planned gap | [Keep #22](https://github.com/flyingrobots/keep/issues/22), [Keep #23](https://github.com/flyingrobots/keep/issues/23) | +| `KEEP-RECONSTRUCT-010` | Durable reconstruction names its view and returns either authenticated success, evidenced refusal, or operational failure without hidden whole-blob allocation. | Durable consumer conformance model | Public API integration, memory, recovery, and crash-injection tests | Planned gap | [Keep #22](https://github.com/flyingrobots/keep/issues/22), [Keep #23](https://github.com/flyingrobots/keep/issues/23) | diff --git a/tests/range_read_failures.rs b/tests/range_read_failures.rs index 7b7822d..1a9b5c0 100644 --- a/tests/range_read_failures.rs +++ b/tests/range_read_failures.rs @@ -9,7 +9,7 @@ use keep::{ ByteLength, ByteOffset, ByteRange, LayoutEntryLimit, RangeReadError, ReferenceStore, ReferenceStoreCapacity, }; -use support::{FailingWriter, LyingWriter, ZeroWriter}; +use support::{FailingWriter, LyingWriter, PrefixThenFailWriter, ZeroWriter}; #[test] fn broken_range_writers_preserve_exact_failure_boundaries() -> Result<(), Box> { @@ -66,3 +66,44 @@ fn broken_range_writers_preserve_exact_failure_boundaries() -> Result<(), Box Result<(), Box> { + let source = b"an accepted range prefix remains untrusted without a receipt"; + let mut store = ReferenceStore::new(ReferenceStoreCapacity::new(1_048_576)); + let mut reader = Cursor::new(source); + let published = store + .stage(&mut reader, LayoutEntryLimit::MAXIMUM)? + .commit(&mut store)?; + let requested = ByteRange::new(ByteOffset::new(3), ByteLength::new(13))?; + let accepted_length = 5_usize; + let mut output = PrefixThenFailWriter::new(accepted_length)?; + + let error = store + .read_range(published.target(), requested, &mut output) + .err() + .ok_or("prefix-failing writer unexpectedly accepted a range")?; + + assert!(matches!( + error, + RangeReadError::Write { + layout, + bytes_written, + ref source + } if layout == published.layout_id() + && bytes_written.get() == u64::try_from(accepted_length)? + && source.kind() == ErrorKind::PermissionDenied + )); + let requested_start = usize::try_from(requested.offset().get())?; + let accepted_end = requested_start + .checked_add(accepted_length) + .ok_or("accepted range overflow")?; + assert_eq!( + output.bytes(), + source + .get(requested_start..accepted_end) + .ok_or("accepted range")? + ); + assert!(Error::source(&error).is_some()); + Ok(()) +} diff --git a/tests/streaming_cas/refusal_laws.rs b/tests/streaming_cas/refusal_laws.rs index 32a738c..5935162 100644 --- a/tests/streaming_cas/refusal_laws.rs +++ b/tests/streaming_cas/refusal_laws.rs @@ -9,7 +9,7 @@ use keep::{ }; use crate::layout_mutation_support::mutation_cases; -use crate::support::{FailingWriter, LyingWriter, ZeroWriter}; +use crate::support::{FailingWriter, LyingWriter, PrefixThenFailWriter, ZeroWriter}; #[test] fn malformed_layout_refuses_before_chunk_lookup_or_output() -> Result<(), Box> { @@ -124,6 +124,39 @@ fn broken_writers_preserve_exact_failure_boundaries() -> Result<(), Box Result<(), Box> { + let source = b"an accepted prefix remains untrusted without a receipt"; + let mut store = ReferenceStore::new(ReferenceStoreCapacity::new(1_048_576)); + let mut reader = Cursor::new(source); + let published = store + .stage(&mut reader, LayoutEntryLimit::MAXIMUM)? + .commit(&mut store)?; + let accepted_length = 7_usize; + let mut output = PrefixThenFailWriter::new(accepted_length)?; + + let error = store + .reconstruct(published.target(), &mut output) + .err() + .ok_or("prefix-failing writer unexpectedly reconstructed")?; + + assert!(matches!( + error, + ReconstructionError::Write { + bytes_written, + ref source, + .. + } if bytes_written.get() == u64::try_from(accepted_length)? + && source.kind() == ErrorKind::PermissionDenied + )); + assert_eq!( + output.bytes(), + source.get(..accepted_length).ok_or("prefix")? + ); + assert!(Error::source(&error).is_some()); + Ok(()) +} + #[test] fn failed_ingestion_remains_distinct_from_reconstruction_refusal() { let store = ReferenceStore::new(ReferenceStoreCapacity::new(0)); diff --git a/tests/support/byte_writers.rs b/tests/support/byte_writers.rs index 783367f..3711434 100644 --- a/tests/support/byte_writers.rs +++ b/tests/support/byte_writers.rs @@ -12,6 +12,12 @@ pub(crate) struct PartitionWriter<'a> { /// Writer that deterministically refuses every byte. pub(crate) struct FailingWriter; +/// Writer that accepts one deterministic nonempty prefix, then fails. +pub(crate) struct PrefixThenFailWriter { + bytes: Vec, + prefix_length: usize, +} + impl Write for FailingWriter { fn write(&mut self, _buffer: &[u8]) -> io::Result { Err(io::Error::new( @@ -25,6 +31,53 @@ impl Write for FailingWriter { } } +impl PrefixThenFailWriter { + /// Constructs a sink that accepts at most `prefix_length` bytes once. + /// + /// # Errors + /// + /// Returns [`ErrorKind::InvalidInput`] for a zero prefix length. + pub(crate) fn new(prefix_length: usize) -> io::Result { + if prefix_length == 0 { + return Err(io::Error::new( + ErrorKind::InvalidInput, + "accepted prefix length must be positive", + )); + } + Ok(Self { + bytes: Vec::new(), + prefix_length, + }) + } + + /// Returns the exact prefix accepted before failure. + #[must_use] + pub(crate) fn bytes(&self) -> &[u8] { + &self.bytes + } +} + +impl Write for PrefixThenFailWriter { + fn write(&mut self, buffer: &[u8]) -> io::Result { + if !self.bytes.is_empty() { + return Err(io::Error::new( + ErrorKind::PermissionDenied, + "fixture refusal after prefix", + )); + } + let accepted_length = self.prefix_length.min(buffer.len()); + let accepted = buffer + .get(..accepted_length) + .ok_or_else(|| io::Error::new(ErrorKind::InvalidInput, "invalid prefix length"))?; + self.bytes.extend_from_slice(accepted); + Ok(accepted_length) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + /// Broken writer that reports one byte beyond the supplied buffer. pub(crate) struct LyingWriter; diff --git a/tests/support/mod.rs b/tests/support/mod.rs index 16801a2..9f14305 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -14,7 +14,9 @@ use std::io; use keep::{ChunkSpan, FastCdc}; pub(crate) use byte_readers::{FailingReader, LyingReader, PartitionReader}; -pub(crate) use byte_writers::{FailingWriter, LyingWriter, PartitionWriter, ZeroWriter}; +pub(crate) use byte_writers::{ + FailingWriter, LyingWriter, PartitionWriter, PrefixThenFailWriter, ZeroWriter, +}; const LAYOUTS: &str = include_str!("../../conformance/layout/v1/layouts.tsv");