Skip to main content

loonfs_core/storage/
content.rs

1//! Content object reads and writes: minting immutable identities,
2//! validating references, and verified read-back.
3
4use crate::error::CoreError;
5use crate::namespace::catalog::{load_namespace_content_store_id, VerifiedNamespaceCatalogEntry};
6use crate::storage::content_admission::{ContentAdmission, PreparedContent};
7use bytes::Bytes;
8use futures::StreamExt;
9use loonfs_api::{
10    AuthoritativePathEntry, ChecksumAlgorithm, ContentId, ContentRef, ContentRefValidationError,
11    ContentStoreId, NamespaceId, Sha256, StorageChecksum, StreamingChecksum,
12};
13use loonfs_objectstore::keys::content_blob;
14use loonfs_objectstore::{ByteRange, ByteStream, ObjectStore, ObjectStoreError, PutMode};
15use serde::{Deserialize, Serialize};
16use std::num::NonZeroU64;
17use std::sync::{Arc, Mutex};
18use thiserror::Error;
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub(crate) struct ValidatedDurableContent {
22    pub content_ref: ContentRef,
23    pub object_key: String,
24    pub file_size_bytes: u64,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub(crate) struct ReadDurableContent {
29    pub validated: ValidatedDurableContent,
30    pub bytes: Vec<u8>,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
34pub struct StoredContent {
35    pub content_store_id: ContentStoreId,
36    pub object_key: String,
37    pub content_ref: ContentRef,
38    pub file_size_bytes: u64,
39    #[serde(skip)]
40    _write_acknowledged: StoredContentWriteAcknowledgement,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44struct StoredContentWriteAcknowledgement;
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Error)]
47pub enum DurableContentValidationError {
48    #[error("invalid content reference: {0}")]
49    InvalidContentRef(ContentRefValidationError),
50    #[error("missing content object `{object_key}`")]
51    MissingContentObject { object_key: String },
52    #[error("content length mismatch for `{object_key}`: expected {expected}, actual {actual}")]
53    ContentLengthMismatch {
54        object_key: String,
55        expected: u64,
56        actual: u64,
57    },
58    #[error(
59        "content checksum mismatch for `{object_key}`: expected `{expected}`, actual `{actual}`"
60    )]
61    ContentChecksumMismatch {
62        object_key: String,
63        expected: String,
64        actual: String,
65    },
66    #[error(
67        "content checksum for `{object_key}` uses `{algorithm}`, which this build cannot recompute"
68    )]
69    ContentChecksumUnverifiable {
70        object_key: String,
71        algorithm: ChecksumAlgorithm,
72    },
73    #[error(
74        "stored content belongs to content store `{actual}`, not namespace-bound store `{expected}`"
75    )]
76    ContentStoreMismatch {
77        expected: ContentStoreId,
78        actual: ContentStoreId,
79    },
80    #[error("object store error for `{object_key}`: {message}")]
81    Store { object_key: String, message: String },
82}
83
84pub(crate) async fn validate_durable_content_reference<S: ObjectStore + ?Sized>(
85    store: &S,
86    content_store_id: &ContentStoreId,
87    content_ref: &ContentRef,
88) -> Result<ValidatedDurableContent, DurableContentValidationError> {
89    let object_key = content_object_key_for_ref(content_store_id, content_ref)?;
90    validate_content_size(store, &object_key, content_ref).await?;
91
92    let bytes = load_required_object(store, &object_key).await?;
93    validate_loaded_content_bytes(object_key, content_ref, &bytes)
94}
95
96/// Prepares content from an acknowledged LoonFS-managed durable write.
97///
98/// Consuming [`StoredContent`] ties the proof to the successful return from
99/// [`store_bytes_as_content`] or [`store_bytes_as_content_with_store_id`]. The
100/// verified catalog prevents pairing that acknowledgement with an unrelated
101/// namespace binding.
102pub fn prepare_stored_content(
103    catalog: &VerifiedNamespaceCatalogEntry,
104    stored_content: StoredContent,
105) -> Result<PreparedContent, DurableContentValidationError> {
106    if stored_content.content_store_id != *catalog.content_store_id() {
107        return Err(DurableContentValidationError::ContentStoreMismatch {
108            expected: catalog.content_store_id().clone(),
109            actual: stored_content.content_store_id,
110        });
111    }
112    let content_store_id = stored_content.content_store_id;
113    let content_ref = stored_content.content_ref;
114    let admission = ContentAdmission::for_durable_content_write(content_store_id, content_ref);
115    Ok(PreparedContent::from_admission(admission))
116}
117
118/// Fully validates an existing durable content reference for publication.
119///
120/// The verified catalog selects the store to validate. This performs one
121/// object HEAD followed by one full GET and checksum check.
122pub async fn prepare_existing_content_ref<S: ObjectStore + ?Sized>(
123    store: &S,
124    catalog: &VerifiedNamespaceCatalogEntry,
125    content_ref: ContentRef,
126) -> Result<PreparedContent, DurableContentValidationError> {
127    let content_store_id = catalog.content_store_id();
128    validate_durable_content_reference(store, content_store_id, &content_ref).await?;
129    let admission =
130        ContentAdmission::for_durable_content_write(content_store_id.clone(), content_ref);
131    Ok(PreparedContent::from_admission(admission))
132}
133
134/// Verifies the object a reference names against that reference, from the
135/// provider's own stored checksum and size.
136///
137/// This is the completion check for bytes that never passed through the
138/// LoonFS server. It verifies rather than trusts: the presigned write is
139/// checksum-bound, but a provider that accepts a wrong claim at assembly
140/// time (Cloudflare R2 does, at multipart completion) would otherwise leave
141/// a corrupt object publishable. One `HeadObject` with checksum mode enabled
142/// answers both questions and moves no payload.
143///
144/// A caller that gets a mismatch owns the repair: the object sits at a
145/// random id nothing references yet, so deleting it costs nothing and
146/// leaving it would leak.
147pub(crate) async fn verify_durable_content_checksum<S: ObjectStore + ?Sized>(
148    store: &S,
149    content_store_id: &ContentStoreId,
150    content_ref: &ContentRef,
151) -> Result<(), DurableContentValidationError> {
152    let object_key = content_object_key_for_ref(content_store_id, content_ref)?;
153    let stored = match store.head_stored_checksum(&object_key).await {
154        Ok(Some(stored)) => stored,
155        Ok(None) => return Err(DurableContentValidationError::MissingContentObject { object_key }),
156        Err(err) => {
157            return Err(DurableContentValidationError::Store {
158                object_key,
159                message: err.message(),
160            })
161        }
162    };
163
164    if stored.size_bytes != content_ref.size_bytes {
165        return Err(DurableContentValidationError::ContentLengthMismatch {
166            object_key,
167            expected: content_ref.size_bytes,
168            actual: stored.size_bytes,
169        });
170    }
171    if stored.storage_checksum != content_ref.storage_checksum {
172        return Err(DurableContentValidationError::ContentChecksumMismatch {
173            object_key,
174            expected: describe_checksum(&content_ref.storage_checksum),
175            actual: describe_checksum(&stored.storage_checksum),
176        });
177    }
178    Ok(())
179}
180
181/// Removes the content object an upload session owned but never published.
182///
183/// The id is random and an upload session is the only thing that can name
184/// one before publication, so exactly one session is ever talking about this
185/// object and no metadata can reference it. Deleting is therefore safe and
186/// keeping it would leak bytes nobody can name again. This runs strictly
187/// after the durable transition that made the session terminal, and it is
188/// idempotent, so a cleanup lost to a crash is simply repeated by the next
189/// garbage-collection pass — which is why a failure here is logged rather
190/// than propagated.
191pub(crate) async fn delete_unpublished_content_object<S: ObjectStore + ?Sized>(
192    store: &S,
193    content_store_id: &ContentStoreId,
194    content_id: &ContentId,
195) {
196    let object_key = content_blob(content_store_id.as_str(), content_id);
197    if let Err(error) = store.delete(&object_key).await {
198        tracing::warn!(
199            content_id = %content_id,
200            error = %error,
201            "failed to remove the content object of a terminated upload session"
202        );
203    }
204}
205
206/// Abandons the provider multipart upload a terminated session opened.
207///
208/// Aborting is safe whatever the upload's real state: an upload that already
209/// assembled its object survives the abort untouched, and one the provider
210/// has never heard of succeeds anyway. So this runs strictly after the
211/// durable transition without first proving what it is cleaning up, and a
212/// failure is logged rather than propagated — the next garbage-collection
213/// pass repeats it from the terminal record.
214pub(crate) async fn abort_unpublished_multipart_upload<S: ObjectStore + ?Sized>(
215    store: &S,
216    content_store_id: &ContentStoreId,
217    content_id: &ContentId,
218    provider_upload_id: &str,
219) {
220    let object_key = content_blob(content_store_id.as_str(), content_id);
221    if let Err(error) = store
222        .abort_multipart_upload(&object_key, provider_upload_id)
223        .await
224    {
225        tracing::warn!(
226            content_id = %content_id,
227            error = %error,
228            "failed to abandon the multipart upload of a terminated upload session"
229        );
230    }
231}
232
233/// Bytes one ranged read of a content object fetches, and therefore the most
234/// of that object a streaming read holds at once.
235///
236/// The same 8 MiB the write path moves a large payload in
237/// ([`loonfs_objectstore::PROVIDER_MULTIPART_PART_BYTES`]): one transfer unit
238/// for both directions, large enough that per-request overhead disappears
239/// against the payload on a large object, small enough that a read's memory
240/// is a fixed few megabytes whatever the object's size.
241pub const CONTENT_READ_CHUNK_BYTES: u64 = 8 * 1024 * 1024;
242
243/// One file's current content, read as fixed-size ranged chunks.
244///
245/// This is the streaming twin of the buffered content read, for a reader that
246/// must not hold what it reads: chunks are fetched one range at a time and
247/// the verifying digest is folded as they go, so a 50 GiB object costs one
248/// chunk of memory rather than 50 GiB. It verifies exactly what the buffered
249/// read verifies — the declared size, and the reference's trusted whole-file
250/// SHA-256 when it has one, otherwise its own storage checksum, which for a
251/// provider-assembled object is the only full-object evidence there is. A
252/// reference whose checksum this build cannot recompute is refused when the
253/// stream is opened, before any byte is fetched, rather than after.
254///
255/// The object is immutable and named by a random content id, so nothing can
256/// rewrite it under a reader: chunk *n* and chunk *n+1* are always from the
257/// same object, and no revalidation between them is needed or done.
258///
259/// Verification lands on the final [`Self::next_chunk`] call — the one that
260/// reports the end of the content. A caller that stops early stops with
261/// unverified bytes, which is what streaming means and why the buffered read
262/// stays for callers that want the whole answer or none of it.
263pub struct FileContentStream<S> {
264    store: S,
265    entry: AuthoritativePathEntry,
266    object_key: String,
267    content_ref: ContentRef,
268    chunk_bytes: NonZeroU64,
269    /// Offset the next ranged read starts at; also how much has been read.
270    next_offset: u64,
271    /// Offset this stream was opened at. Zero for a read of the whole
272    /// object, and the length of what the caller already holds for a
273    /// resumed one.
274    resumed_from: u64,
275    /// How much of that head start the caller has folded in so far. The
276    /// stream fetches nothing until this reaches `resumed_from`, because
277    /// the verdict is over the whole object either way.
278    prefix_folded: u64,
279    /// The checksum the complete object must produce, folded so far.
280    digest: StreamingChecksum,
281    /// The value `digest` is closed against.
282    expected: StorageChecksum,
283    /// The verdict on the complete object, once there is one. Kept because a
284    /// digest can only be closed once: without it, asking again after the end
285    /// would fold a second, empty digest and report a mismatch that is not
286    /// one.
287    completion: Option<Result<(), DurableContentValidationError>>,
288}
289
290impl<S: ObjectStore> FileContentStream<S> {
291    /// Opens a streaming read of the object `content_ref` names.
292    ///
293    /// One `HeadObject` proves the object exists and is exactly as long as
294    /// the reference claims before any payload moves, which is what lets a
295    /// wrong-sized object fail without a partial answer having been handed
296    /// out. The reference's checksum algorithm is resolved here for the same
297    /// reason.
298    /// `start_offset` is where the caller already is: bytes below it are
299    /// never fetched, and [`Self::fold_resumed_prefix`] is how they still
300    /// reach the digest.
301    pub(crate) async fn open(
302        store: S,
303        content_store_id: &ContentStoreId,
304        entry: AuthoritativePathEntry,
305        content_ref: ContentRef,
306        chunk_bytes: NonZeroU64,
307        start_offset: u64,
308    ) -> Result<Self, DurableContentValidationError> {
309        let object_key = content_object_key_for_ref(content_store_id, &content_ref)?;
310        validate_content_size(&store, &object_key, &content_ref).await?;
311        let expected = verifiable_checksum(&content_ref);
312        let digest = StreamingChecksum::for_algorithm(expected.algorithm).ok_or({
313            DurableContentValidationError::ContentChecksumUnverifiable {
314                object_key: object_key.clone(),
315                algorithm: content_ref.storage_checksum.algorithm,
316            }
317        })?;
318        Ok(Self {
319            store,
320            entry,
321            object_key,
322            content_ref,
323            chunk_bytes,
324            next_offset: start_offset,
325            resumed_from: start_offset,
326            prefix_folded: 0,
327            digest,
328            expected,
329            completion: None,
330        })
331    }
332
333    /// Hands the stream part of what the caller already holds, in order,
334    /// from the object's first byte.
335    ///
336    /// A resumed read still reports on the whole object, so the bytes it
337    /// will never fetch have to be folded into the same digest that closes
338    /// over the ones it does. Feeding the wrong bytes fails verification at
339    /// the end, which is exactly right: the reference is the authority on
340    /// what the object holds, not the partial copy on the caller's disk.
341    pub fn fold_resumed_prefix(&mut self, bytes: &[u8]) {
342        self.digest.update(bytes);
343        self.prefix_folded = self.prefix_folded.saturating_add(bytes.len() as u64);
344    }
345
346    /// The authoritative metadata entry the path resolved to.
347    pub fn entry(&self) -> &AuthoritativePathEntry {
348        &self.entry
349    }
350
351    /// Complete length of the content this stream reads.
352    pub fn size_bytes(&self) -> u64 {
353        self.content_ref.size_bytes
354    }
355
356    /// Fetches the next chunk, or reports the end of a verified read.
357    ///
358    /// `Ok(None)` is returned only after the folded digest and the byte count
359    /// agree with the reference; a mismatch fails this call instead. Chunks
360    /// arrive in order from wherever the stream started, and every one but
361    /// the last is exactly the chunk size this stream was opened with.
362    ///
363    /// This is the method callers outside this crate hold, so it speaks the
364    /// crate's error type: a content object that disagrees with its reference
365    /// is namespace corruption, and it is classified as such here rather than
366    /// at every call site. A resumed stream that has not been told what it
367    /// skipped is the caller's own mistake instead, and says so before
368    /// anything is fetched.
369    pub async fn next_chunk(&mut self) -> Result<Option<Bytes>, CoreError> {
370        if self.prefix_folded != self.resumed_from {
371            return Err(CoreError::ResumePrefixIncomplete {
372                start_offset: self.resumed_from,
373                folded: self.prefix_folded,
374            });
375        }
376        Ok(self.next_verified_chunk().await?)
377    }
378
379    async fn next_verified_chunk(
380        &mut self,
381    ) -> Result<Option<Bytes>, DurableContentValidationError> {
382        if self.next_offset == self.content_ref.size_bytes {
383            return self.completion().map(|()| None);
384        }
385        let end_exclusive = self
386            .next_offset
387            .saturating_add(self.chunk_bytes.get())
388            .min(self.content_ref.size_bytes);
389        let bytes = match self
390            .store
391            .get(
392                &self.object_key,
393                Some(ByteRange {
394                    start_inclusive: self.next_offset,
395                    end_exclusive,
396                }),
397            )
398            .await
399        {
400            Ok(Some(bytes)) => bytes,
401            Ok(None) => {
402                return Err(DurableContentValidationError::MissingContentObject {
403                    object_key: self.object_key.clone(),
404                })
405            }
406            Err(err) => {
407                return Err(DurableContentValidationError::Store {
408                    object_key: self.object_key.clone(),
409                    message: err.message(),
410                })
411            }
412        };
413        // A range ending past the object is truncated, so a short answer is
414        // an object that ended earlier than its reference says it does.
415        if bytes.len() as u64 != end_exclusive - self.next_offset {
416            return Err(DurableContentValidationError::ContentLengthMismatch {
417                object_key: self.object_key.clone(),
418                expected: self.content_ref.size_bytes,
419                actual: self.next_offset + bytes.len() as u64,
420            });
421        }
422        self.digest.update(&bytes);
423        self.next_offset += bytes.len() as u64;
424        Ok(Some(bytes))
425    }
426
427    /// The verdict on the complete object: computed the first time the end is
428    /// reached, and repeated on every later ask.
429    ///
430    /// The byte count needs no check of its own here. Every chunk is required
431    /// to arrive exactly as long as it was asked for, and no chunk is asked
432    /// for past the declared size, so reaching this point *is* having folded
433    /// exactly `size_bytes` — the resumed head start, which the first
434    /// [`Self::next_chunk`] refuses to start without, plus everything
435    /// fetched from it to the end. The object's own length was checked
436    /// against the reference by the head request [`Self::open`] made.
437    fn completion(&mut self) -> Result<(), DurableContentValidationError> {
438        let verdict = match self.completion.take() {
439            Some(verdict) => verdict,
440            None => self.verify_complete(),
441        };
442        self.completion = Some(verdict.clone());
443        verdict
444    }
445
446    /// Closes the digest over everything read and holds it to the reference.
447    fn verify_complete(&mut self) -> Result<(), DurableContentValidationError> {
448        // Closing consumes the digest, which is why this runs exactly once.
449        let digest = std::mem::replace(
450            &mut self.digest,
451            StreamingChecksum::for_algorithm(self.expected.algorithm)
452                .expect("an algorithm this stream already folded stays recomputable"),
453        );
454        let actual = digest.finish();
455        if actual != self.expected {
456            return Err(DurableContentValidationError::ContentChecksumMismatch {
457                object_key: self.object_key.clone(),
458                expected: describe_checksum(&self.expected),
459                actual: describe_checksum(&actual),
460            });
461        }
462        Ok(())
463    }
464}
465
466impl<S> std::fmt::Debug for FileContentStream<S> {
467    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
468        f.debug_struct("FileContentStream")
469            .field("object_key", &self.object_key)
470            .field("size_bytes", &self.content_ref.size_bytes)
471            .field("next_offset", &self.next_offset)
472            .finish_non_exhaustive()
473    }
474}
475
476pub(crate) async fn read_durable_content_bytes<S: ObjectStore + ?Sized>(
477    store: &S,
478    content_store_id: &ContentStoreId,
479    content_ref: &ContentRef,
480) -> Result<ReadDurableContent, DurableContentValidationError> {
481    let object_key = content_object_key_for_ref(content_store_id, content_ref)?;
482    let bytes = load_required_object(store, &object_key).await?;
483    let validated = validate_loaded_content_bytes(object_key, content_ref, &bytes)?;
484
485    Ok(ReadDurableContent { validated, bytes })
486}
487
488pub(crate) fn content_object_key_for_ref(
489    content_store_id: &ContentStoreId,
490    content_ref: &ContentRef,
491) -> Result<String, DurableContentValidationError> {
492    content_ref
493        .validate()
494        .map_err(DurableContentValidationError::InvalidContentRef)?;
495    Ok(content_blob(
496        content_store_id.as_str(),
497        &content_ref.content_id,
498    ))
499}
500
501/// Checks fetched bytes against everything the reference claims about them.
502///
503/// The whole-file SHA-256 is the check whenever it is present; a reference
504/// that carries only a CRC — which direct multipart produces, because a
505/// provider-assembled object is never hashed by us — is verified by that
506/// CRC instead. A reference whose checksum this build cannot recompute is
507/// refused rather than waved through: an unverifiable read is not a
508/// verified one.
509fn validate_loaded_content_bytes(
510    object_key: String,
511    content_ref: &ContentRef,
512    bytes: &[u8],
513) -> Result<ValidatedDurableContent, DurableContentValidationError> {
514    let actual_size = bytes.len() as u64;
515    if actual_size != content_ref.size_bytes {
516        return Err(DurableContentValidationError::ContentLengthMismatch {
517            object_key,
518            expected: content_ref.size_bytes,
519            actual: actual_size,
520        });
521    }
522
523    let expected = verifiable_checksum(content_ref);
524    match expected.matches(bytes) {
525        Some(true) => {}
526        Some(false) => {
527            let actual = match expected.algorithm {
528                ChecksumAlgorithm::Sha256 => StorageChecksum::sha256(bytes),
529                _ => StorageChecksum::crc64nvme(bytes),
530            };
531            return Err(DurableContentValidationError::ContentChecksumMismatch {
532                object_key,
533                expected: describe_checksum(&expected),
534                actual: describe_checksum(&actual),
535            });
536        }
537        None => {
538            return Err(DurableContentValidationError::ContentChecksumUnverifiable {
539                object_key,
540                algorithm: content_ref.storage_checksum.algorithm,
541            })
542        }
543    }
544
545    Ok(ValidatedDurableContent {
546        content_ref: content_ref.clone(),
547        object_key,
548        file_size_bytes: actual_size,
549    })
550}
551
552/// The checksum a read holds these bytes to: the trusted whole-file digest
553/// when one exists, and otherwise the reference's own storage checksum,
554/// which for a provider-assembled object is the only evidence there is.
555fn verifiable_checksum(content_ref: &ContentRef) -> StorageChecksum {
556    match &content_ref.whole_file_sha256 {
557        Some(digest) => StorageChecksum {
558            algorithm: ChecksumAlgorithm::Sha256,
559            value: digest.clone(),
560        },
561        None => content_ref.storage_checksum.clone(),
562    }
563}
564
565fn describe_checksum(checksum: &StorageChecksum) -> String {
566    format!("{}:{}", checksum.algorithm, checksum.value)
567}
568
569/// Existence and size from one HEAD, used as cheap prevalidation before the
570/// authoritative read-and-hash: a wrong-sized object fails without being
571/// downloaded.
572async fn validate_content_size<S: ObjectStore + ?Sized>(
573    store: &S,
574    object_key: &str,
575    content_ref: &ContentRef,
576) -> Result<(), DurableContentValidationError> {
577    let metadata = match store.head(object_key).await {
578        Ok(Some(metadata)) => metadata,
579        Ok(None) => {
580            return Err(DurableContentValidationError::MissingContentObject {
581                object_key: object_key.to_owned(),
582            })
583        }
584        Err(err) => {
585            return Err(DurableContentValidationError::Store {
586                object_key: object_key.to_owned(),
587                message: err.message(),
588            })
589        }
590    };
591
592    if metadata.size_bytes != content_ref.size_bytes {
593        return Err(DurableContentValidationError::ContentLengthMismatch {
594            object_key: object_key.to_owned(),
595            expected: content_ref.size_bytes,
596            actual: metadata.size_bytes,
597        });
598    }
599    Ok(())
600}
601
602/// Plants durable content under a fresh identity, resolving the namespace's
603/// content store first. See [`store_bytes_as_content_with_store_id`] for
604/// what this is for and what it is not.
605#[tracing::instrument(
606    level = "info",
607    name = "loonfs.phase",
608    err,
609    skip_all,
610    fields(phase = "write_content_blob", key_class = "content_blob")
611)]
612pub async fn store_bytes_as_content<S: ObjectStore + ?Sized>(
613    store: &S,
614    namespace_id: &NamespaceId,
615    bytes: &[u8],
616) -> Result<StoredContent, CoreError> {
617    let content_store_id = load_namespace_content_store_id(store, namespace_id).await?;
618    store_bytes_as_content_with_store_id(store, content_store_id, bytes).await
619}
620
621/// Plants durable content for a caller that already knows the namespace's
622/// content-store binding.
623///
624/// Every call mints its own content identity, so two writers staging the
625/// same bytes produce two objects rather than racing for one key. Sharing a
626/// key was free deduplication and also a free existence oracle: anyone
627/// allowed to upload could learn whether specific known bytes were already
628/// in a shared content store. Retry idempotency, the thing that dedup was
629/// quietly providing, belongs to the upload session instead.
630///
631/// So does reclamation, which is why this is a fixture rather than a write
632/// path. The object it writes belongs to no session, and a session record is
633/// the only handle anything has on a content object before metadata names
634/// one — so nothing will ever collect it. Production staging opens a session
635/// ([`crate::protocol::stage_owned_bytes`]); immortal bytes are what a test
636/// wants and what a namespace does not.
637pub(crate) async fn store_bytes_as_content_with_store_id<S: ObjectStore + ?Sized>(
638    store: &S,
639    content_store_id: ContentStoreId,
640    bytes: &[u8],
641) -> Result<StoredContent, CoreError> {
642    stage_bytes_under_content_id(store, content_store_id, ContentId::generate(), bytes).await
643}
644
645/// What a streamed staging write established about the content object.
646#[derive(Debug, Clone, PartialEq, Eq)]
647pub(crate) struct StagedStream {
648    /// Identity, length, and the digest folded over the payload on its way
649    /// through. The digest is always over the complete stream: the store
650    /// consumes the body before it evaluates any precondition.
651    pub content_ref: ContentRef,
652    /// Whether the key was already occupied when the write tried to create
653    /// it. Only this session can have written there — the identity is
654    /// random and belongs to one session — so the caller decides whether
655    /// this is its own earlier attempt replayed or a conflicting one, by
656    /// comparing `content_ref` against what the session recorded.
657    pub already_present: bool,
658}
659
660/// Stages a payload that arrives as a stream, hashing it on the way through.
661///
662/// The bytes are never held whole: the digest is folded chunk by chunk as
663/// they are forwarded to the store, and the reference is built from that
664/// digest and the length the store reports back. The result carries a
665/// trusted `whole_file_sha256` for the same reason the buffered path's does
666/// — the LoonFS write path hashed the complete payload itself — and it is
667/// the constructor, not a convention, that guarantees it.
668///
669/// The write is create-only, exactly like the buffered staging write, and
670/// degrades to an overwrite past the store's multipart threshold for the
671/// same reason that one does: a provider assembles a multipart object
672/// unconditionally. On a key named by 128 random bits the condition is a
673/// corruption tripwire rather than a concurrency control, so what it
674/// catches either way is a key occupied by something this session did not
675/// write.
676pub(crate) async fn stage_streamed_under_content_id<S: ObjectStore + ?Sized>(
677    store: &S,
678    content_store_id: ContentStoreId,
679    content_id: ContentId,
680    body: ByteStream,
681) -> Result<StagedStream, CoreError> {
682    let object_key = content_blob(content_store_id.as_str(), &content_id);
683    let observed = Arc::new(Mutex::new(StreamedPayload::default()));
684    let hashed = {
685        let observed = Arc::clone(&observed);
686        body.map(move |chunk| {
687            let chunk = chunk?;
688            let mut observed = observed.lock().unwrap_or_else(|err| err.into_inner());
689            observed.digest.update(&chunk);
690            observed.size_bytes += chunk.len() as u64;
691            Ok(chunk)
692        })
693        .boxed()
694    };
695
696    let stored = store
697        .put_streamed(&object_key, hashed, PutMode::CreateIfAbsent)
698        .await;
699    let observed = std::mem::take(&mut *observed.lock().unwrap_or_else(|err| err.into_inner()));
700    let already_present = match stored {
701        Ok(stored_bytes) if stored_bytes != observed.size_bytes => {
702            return Err(CoreError::Internal(format!(
703                "streamed write of `{object_key}` stored {stored_bytes} bytes, \
704                 but {} passed through this writer",
705                observed.size_bytes
706            )))
707        }
708        Ok(_) => false,
709        // The key is occupied. Only this session can name it, so the caller
710        // decides from the digest whether that was its own earlier attempt.
711        Err(ObjectStoreError::PreconditionFailed { .. }) => true,
712        Err(err) => return Err(CoreError::store(&object_key, &err)),
713    };
714
715    Ok(StagedStream {
716        content_ref: ContentRef::blob_v1_streamed(content_id, observed.size_bytes, observed.digest),
717        already_present,
718    })
719}
720
721/// Reads a payload without writing it anywhere, and reports what it was.
722///
723/// This is how a session that has already staged content answers a repeated
724/// upload: the only way to tell "the same bytes again" from "different
725/// bytes" is to hash them, and the object it already owns must not be
726/// touched while that is decided.
727pub(crate) async fn identify_streamed_payload(
728    content_id: ContentId,
729    mut body: ByteStream,
730) -> Result<ContentRef, CoreError> {
731    let mut observed = StreamedPayload::default();
732    while let Some(chunk) = body.next().await {
733        let chunk = chunk.map_err(|err| CoreError::store("upload body", &err))?;
734        observed.digest.update(&chunk);
735        observed.size_bytes += chunk.len() as u64;
736    }
737    Ok(ContentRef::blob_v1_streamed(
738        content_id,
739        observed.size_bytes,
740        observed.digest,
741    ))
742}
743
744/// What a streamed payload amounted to, folded as it passed through.
745#[derive(Debug, Default)]
746struct StreamedPayload {
747    digest: Sha256,
748    size_bytes: u64,
749}
750
751/// Stages bytes under an identity the caller already allocated, for writers
752/// that minted the id earlier (an upload session allocates at `begin`).
753pub(crate) async fn stage_bytes_under_content_id<S: ObjectStore + ?Sized>(
754    store: &S,
755    content_store_id: ContentStoreId,
756    content_id: ContentId,
757    bytes: &[u8],
758) -> Result<StoredContent, CoreError> {
759    let content_ref = ContentRef::blob_v1(content_id, bytes);
760    let object_key = content_blob(content_store_id.as_str(), &content_ref.content_id);
761    // Create-only plus the byte check stay on this write even though a
762    // random id cannot collide: if this key is ever occupied by different
763    // bytes, that is corruption, and it must fail loudly rather than be
764    // overwritten.
765    store
766        .put_immutable_verified(&object_key, Bytes::copy_from_slice(bytes))
767        .await?;
768
769    Ok(StoredContent {
770        content_store_id,
771        object_key,
772        file_size_bytes: content_ref.size_bytes,
773        content_ref,
774        _write_acknowledged: StoredContentWriteAcknowledgement,
775    })
776}
777
778async fn load_required_object<S: ObjectStore + ?Sized>(
779    store: &S,
780    object_key: &str,
781) -> Result<Vec<u8>, DurableContentValidationError> {
782    match store.get(object_key, None).await {
783        Ok(Some(bytes)) => Ok(bytes.to_vec()),
784        Ok(None) => Err(DurableContentValidationError::MissingContentObject {
785            object_key: object_key.to_owned(),
786        }),
787        Err(err) => Err(DurableContentValidationError::Store {
788            object_key: object_key.to_owned(),
789            message: err.message(),
790        }),
791    }
792}
793
794#[cfg(test)]
795mod tests {
796    use super::{
797        read_durable_content_bytes, store_bytes_as_content_with_store_id,
798        validate_durable_content_reference, verify_durable_content_checksum, CoreError,
799        DurableContentValidationError, FileContentStream, NonZeroU64,
800    };
801    use bytes::Bytes;
802    use loonfs_api::{
803        AuthoritativePathEntry, ChecksumAlgorithm, ContentId, ContentRef, ContentRefKind,
804        ContentStoreId, StorageChecksum,
805    };
806    use loonfs_objectstore::keys::content_blob;
807    use loonfs_objectstore::local_fs_store::LocalFsStore;
808    use loonfs_objectstore::ObjectStore;
809    use loonfs_test_support::stores::{CountingStore, KeyPredicate, OperationClass};
810    use tempfile::tempdir;
811
812    fn content_ref(bytes: &[u8]) -> ContentRef {
813        ContentRef::blob_v1(ContentId::generate(), bytes)
814    }
815
816    #[tokio::test]
817    async fn validate_content_ref_success() {
818        let (_temp_dir, store, content_store_id) = test_store();
819        let bytes = b"whole file bytes";
820        let content_ref = content_ref(bytes);
821        put_content_object(&store, &content_store_id, &content_ref, bytes).await;
822
823        let validated = validate_durable_content_reference(&store, &content_store_id, &content_ref)
824            .await
825            .expect("validate content ref");
826        assert_eq!(validated.content_ref, content_ref);
827        assert_eq!(validated.file_size_bytes, bytes.len() as u64);
828    }
829
830    #[tokio::test]
831    async fn validate_content_ref_reads_and_hashes_the_bytes() {
832        let (_temp_dir, inner, content_store_id) = test_store();
833        let store = CountingStore::new(inner, KeyPredicate::content_blob());
834        let bytes = b"whole file bytes";
835        let content_ref = content_ref(bytes);
836        put_content_object(&store, &content_store_id, &content_ref, bytes).await;
837
838        store.reset();
839        validate_durable_content_reference(&store, &content_store_id, &content_ref)
840            .await
841            .expect("validate content ref");
842        assert_eq!(store.count(OperationClass::Read), 1);
843    }
844
845    #[tokio::test]
846    async fn validate_content_ref_accepts_empty_files() {
847        let (_temp_dir, store, content_store_id) = test_store();
848        let bytes = b"";
849        let content_ref = content_ref(bytes);
850        put_content_object(&store, &content_store_id, &content_ref, bytes).await;
851
852        let read = read_durable_content_bytes(&store, &content_store_id, &content_ref)
853            .await
854            .expect("read empty content ref");
855        assert_eq!(read.bytes, bytes);
856        assert_eq!(read.validated.file_size_bytes, 0);
857    }
858
859    #[tokio::test]
860    async fn validate_content_ref_rejects_missing_object() {
861        let (_temp_dir, store, content_store_id) = test_store();
862        let content_ref = content_ref(b"missing");
863
864        let err = validate_durable_content_reference(&store, &content_store_id, &content_ref)
865            .await
866            .expect_err("missing object");
867        assert!(matches!(
868            err,
869            DurableContentValidationError::MissingContentObject { .. }
870        ));
871    }
872
873    #[tokio::test]
874    async fn validate_content_ref_rejects_size_mismatch() {
875        let (_temp_dir, store, content_store_id) = test_store();
876        let mut content_ref = content_ref(b"abc");
877        put_content_object(&store, &content_store_id, &content_ref, b"abc").await;
878        content_ref.size_bytes += 1;
879
880        let err = validate_durable_content_reference(&store, &content_store_id, &content_ref)
881            .await
882            .expect_err("size mismatch");
883        assert!(matches!(
884            err,
885            DurableContentValidationError::ContentLengthMismatch { .. }
886        ));
887    }
888
889    #[tokio::test]
890    async fn validate_content_ref_rejects_checksum_mismatch() {
891        let (_temp_dir, store, content_store_id) = test_store();
892        let expected = content_ref(b"expected");
893        // Same id, different bytes: identity alone can no longer prove
894        // content, so the checksum has to.
895        let planted = ContentRef::blob_v1(expected.content_id.clone(), b"mismatch");
896        put_content_object(&store, &content_store_id, &planted, b"mismatch").await;
897
898        let err = validate_durable_content_reference(&store, &content_store_id, &expected)
899            .await
900            .expect_err("checksum mismatch");
901        assert!(matches!(
902            err,
903            DurableContentValidationError::ContentChecksumMismatch { .. }
904        ));
905    }
906
907    /// A reference whose only evidence is a CRC this build cannot recompute
908    /// must fail the read rather than be waved through unverified.
909    #[tokio::test]
910    async fn read_refuses_a_reference_it_cannot_verify() {
911        let (_temp_dir, store, content_store_id) = test_store();
912        let bytes = b"crc only";
913        let mut content_ref = content_ref(bytes);
914        content_ref.whole_file_sha256 = None;
915        content_ref.storage_checksum = StorageChecksum {
916            algorithm: ChecksumAlgorithm::Crc32c,
917            value: "00000000".to_owned(),
918        };
919        put_content_object(&store, &content_store_id, &content_ref, bytes).await;
920
921        let err = read_durable_content_bytes(&store, &content_store_id, &content_ref)
922            .await
923            .expect_err("unverifiable checksum");
924        assert!(matches!(
925            err,
926            DurableContentValidationError::ContentChecksumUnverifiable { .. }
927        ));
928    }
929
930    /// A direct multipart upload produces a reference whose only evidence is
931    /// the CRC-64/NVME the provider computed over the assembly. Reads must
932    /// verify it — the alternative is a whole write path whose bytes are
933    /// never checked on the way back out.
934    #[tokio::test]
935    async fn read_verifies_a_reference_whose_only_evidence_is_a_crc64nvme() {
936        let (_temp_dir, store, content_store_id) = test_store();
937        let bytes = b"provider-assembled bytes";
938        let content_ref = ContentRef {
939            kind: ContentRefKind::BlobV1,
940            content_id: ContentId::generate(),
941            size_bytes: bytes.len() as u64,
942            storage_checksum: StorageChecksum::crc64nvme(bytes),
943            whole_file_sha256: None,
944        };
945        put_content_object(&store, &content_store_id, &content_ref, bytes).await;
946
947        let read = read_durable_content_bytes(&store, &content_store_id, &content_ref)
948            .await
949            .expect("a crc-only reference verifies by its crc");
950        assert_eq!(read.bytes, bytes);
951
952        // Same length, different bytes: only the checksum can tell.
953        let (_temp_dir, store, content_store_id) = test_store();
954        let planted = ContentRef {
955            storage_checksum: StorageChecksum::crc64nvme(b"provider-assembled BYTES"),
956            ..content_ref.clone()
957        };
958        put_content_object(&store, &content_store_id, &planted, bytes).await;
959        assert!(matches!(
960            read_durable_content_bytes(&store, &content_store_id, &planted)
961                .await
962                .expect_err("crc mismatch"),
963            DurableContentValidationError::ContentChecksumMismatch { .. }
964        ));
965    }
966
967    #[tokio::test]
968    async fn checksum_verification_proves_the_object_without_reading_it() {
969        let (_temp_dir, inner, content_store_id) = test_store();
970        let store = CountingStore::new(inner, KeyPredicate::content_blob());
971        let bytes = b"provider-verified bytes";
972        let content_ref = content_ref(bytes);
973        put_content_object(&store, &content_store_id, &content_ref, bytes).await;
974
975        store.reset();
976        verify_durable_content_checksum(&store, &content_store_id, &content_ref)
977            .await
978            .expect("verify content ref");
979        assert_eq!(
980            store.count(OperationClass::Read),
981            0,
982            "verification reads provider metadata, never the payload"
983        );
984    }
985
986    #[tokio::test]
987    async fn checksum_verification_rejects_missing_size_and_checksum_drift() {
988        let (_temp_dir, store, content_store_id) = test_store();
989        let bytes = b"abc";
990        let content_ref = content_ref(bytes);
991
992        let err = verify_durable_content_checksum(&store, &content_store_id, &content_ref)
993            .await
994            .expect_err("missing object");
995        assert!(matches!(
996            err,
997            DurableContentValidationError::MissingContentObject { .. }
998        ));
999
1000        put_content_object(&store, &content_store_id, &content_ref, bytes).await;
1001        let mut wrong_size = content_ref.clone();
1002        wrong_size.size_bytes += 1;
1003        assert!(matches!(
1004            verify_durable_content_checksum(&store, &content_store_id, &wrong_size)
1005                .await
1006                .expect_err("size mismatch"),
1007            DurableContentValidationError::ContentLengthMismatch { .. }
1008        ));
1009
1010        // The bytes at the key hash to something else: exactly the case the
1011        // completion check exists to catch on a provider that accepts a
1012        // wrong claim.
1013        let mut wrong_checksum = content_ref.clone();
1014        wrong_checksum.storage_checksum = StorageChecksum::sha256(b"other bytes");
1015        wrong_checksum.whole_file_sha256 = Some(wrong_checksum.storage_checksum.value.clone());
1016        assert!(matches!(
1017            verify_durable_content_checksum(&store, &content_store_id, &wrong_checksum)
1018                .await
1019                .expect_err("checksum mismatch"),
1020            DurableContentValidationError::ContentChecksumMismatch { .. }
1021        ));
1022    }
1023
1024    #[tokio::test]
1025    async fn validate_content_ref_rejects_unsupported_kind() {
1026        let (_temp_dir, store, content_store_id) = test_store();
1027        let content_ref = ContentRef {
1028            kind: ContentRefKind::Unsupported("kind_from_the_future".to_owned()),
1029            ..content_ref(b"bytes")
1030        };
1031
1032        let err = validate_durable_content_reference(&store, &content_store_id, &content_ref)
1033            .await
1034            .expect_err("unsupported content ref kind");
1035        assert!(matches!(
1036            err,
1037            DurableContentValidationError::InvalidContentRef(_)
1038        ));
1039    }
1040
1041    /// Two writers staging the same bytes get two objects. There is no
1042    /// shared key to coalesce on, so neither can observe the other.
1043    #[tokio::test]
1044    async fn staging_identical_bytes_twice_mints_two_distinct_objects() {
1045        let (_temp_dir, store, content_store_id) = test_store();
1046        let bytes = b"identical payload";
1047
1048        let first = store_bytes_as_content_with_store_id(&store, content_store_id.clone(), bytes)
1049            .await
1050            .expect("first stage");
1051        let second = store_bytes_as_content_with_store_id(&store, content_store_id, bytes)
1052            .await
1053            .expect("second stage");
1054
1055        assert_ne!(
1056            first.content_ref.content_id, second.content_ref.content_id,
1057            "each staging write owns its own content object"
1058        );
1059        assert_ne!(first.object_key, second.object_key);
1060        assert_eq!(
1061            first.content_ref.storage_checksum, second.content_ref.storage_checksum,
1062            "identical bytes still carry identical evidence"
1063        );
1064        for stored in [&first, &second] {
1065            assert_eq!(
1066                store
1067                    .get(&stored.object_key, None)
1068                    .await
1069                    .expect("read staged object")
1070                    .expect("staged object exists"),
1071                Bytes::from_static(b"identical payload")
1072            );
1073        }
1074    }
1075
1076    /// Chunk size the streaming tests read in, small enough that a
1077    /// many-chunk object is a few kilobytes rather than tens of megabytes.
1078    const TEST_CHUNK_BYTES: u64 = 1024;
1079
1080    fn test_chunk_bytes() -> NonZeroU64 {
1081        NonZeroU64::new(TEST_CHUNK_BYTES).expect("non-zero test chunk size")
1082    }
1083
1084    fn payload(len: usize) -> Vec<u8> {
1085        (0..len).map(|offset| (offset % 251) as u8).collect()
1086    }
1087
1088    fn test_entry() -> AuthoritativePathEntry {
1089        AuthoritativePathEntry {
1090            namespace_id: loonfs_api::NamespaceId::parse("demo").expect("namespace id"),
1091            absolute_path: loonfs_api::AbsolutePath::parse("/file.bin").expect("absolute path"),
1092            inode_id: loonfs_api::InodeId(1),
1093            inode_kind: loonfs_api::InodeKind::File,
1094            head_seq: loonfs_api::ChangeSeq(1),
1095            parent_inode_id: None,
1096            display_name: None,
1097            revision_no: None,
1098            size_bytes: None,
1099            content_ref: None,
1100            committed_at_ms: None,
1101        }
1102    }
1103
1104    async fn open_stream<S: ObjectStore>(
1105        store: S,
1106        content_store_id: &ContentStoreId,
1107        content_ref: &ContentRef,
1108    ) -> Result<FileContentStream<S>, DurableContentValidationError> {
1109        open_stream_at(store, content_store_id, content_ref, 0).await
1110    }
1111
1112    async fn open_stream_at<S: ObjectStore>(
1113        store: S,
1114        content_store_id: &ContentStoreId,
1115        content_ref: &ContentRef,
1116        start_offset: u64,
1117    ) -> Result<FileContentStream<S>, DurableContentValidationError> {
1118        FileContentStream::open(
1119            store,
1120            content_store_id,
1121            test_entry(),
1122            content_ref.clone(),
1123            test_chunk_bytes(),
1124            start_offset,
1125        )
1126        .await
1127    }
1128
1129    /// A streamed read hands back the object in chunks of the size it was
1130    /// opened with, in order, and ends only after verifying the whole thing.
1131    #[tokio::test]
1132    async fn a_streamed_read_returns_the_object_one_chunk_at_a_time() {
1133        let (_temp_dir, store, content_store_id) = test_store();
1134        let bytes = payload(3 * TEST_CHUNK_BYTES as usize + 7);
1135        let content_ref = content_ref(&bytes);
1136        put_content_object(&store, &content_store_id, &content_ref, &bytes).await;
1137
1138        let mut stream = open_stream(&store, &content_store_id, &content_ref)
1139            .await
1140            .expect("open stream");
1141        let mut chunks = Vec::new();
1142        while let Some(chunk) = stream.next_chunk().await.expect("chunk") {
1143            chunks.push(chunk);
1144        }
1145
1146        assert_eq!(chunks.len(), 4, "three full chunks and the remainder");
1147        for chunk in &chunks[..3] {
1148            assert_eq!(chunk.len() as u64, TEST_CHUNK_BYTES);
1149        }
1150        assert_eq!(chunks[3].len(), 7);
1151        assert_eq!(chunks.concat(), bytes, "the object arrives byte-identical");
1152    }
1153
1154    /// The end is an answer, not an event: a caller that asks again after it
1155    /// gets the same verdict rather than a digest closed a second time over
1156    /// nothing.
1157    #[tokio::test]
1158    async fn a_finished_stream_repeats_its_verdict() {
1159        let (_temp_dir, store, content_store_id) = test_store();
1160        let bytes = payload(TEST_CHUNK_BYTES as usize + 3);
1161        let content_ref = content_ref(&bytes);
1162        put_content_object(&store, &content_store_id, &content_ref, &bytes).await;
1163
1164        let mut stream = open_stream(&store, &content_store_id, &content_ref)
1165            .await
1166            .expect("open stream");
1167        while stream.next_chunk().await.expect("chunk").is_some() {}
1168        assert!(stream.next_chunk().await.expect("verified end").is_none());
1169        assert!(stream.next_chunk().await.expect("verified end").is_none());
1170    }
1171
1172    /// A resumed read fetches only what it does not already have, and still
1173    /// closes its verdict over the whole object.
1174    #[tokio::test]
1175    async fn a_resumed_read_fetches_only_the_rest_and_verifies_all_of_it() {
1176        let (_temp_dir, inner, content_store_id) = test_store();
1177        let store = CountingStore::new(inner, KeyPredicate::content_blob());
1178        let bytes = payload(3 * TEST_CHUNK_BYTES as usize);
1179        let content_ref = content_ref(&bytes);
1180        put_content_object(&store, &content_store_id, &content_ref, &bytes).await;
1181
1182        let held = 2 * TEST_CHUNK_BYTES as usize;
1183        store.reset();
1184        let mut stream = open_stream_at(&store, &content_store_id, &content_ref, held as u64)
1185            .await
1186            .expect("open stream");
1187        stream.fold_resumed_prefix(&bytes[..held]);
1188        let mut fetched = Vec::new();
1189        while let Some(chunk) = stream.next_chunk().await.expect("chunk") {
1190            fetched.extend_from_slice(&chunk);
1191        }
1192        assert_eq!(
1193            fetched,
1194            bytes[held..],
1195            "a resumed read hands back only what it fetched"
1196        );
1197        assert_eq!(
1198            store.count(OperationClass::Read),
1199            1,
1200            "one chunk was left to fetch, so one ranged read happened"
1201        );
1202    }
1203
1204    /// The prefix is part of the verdict, not a formality: bytes that are
1205    /// not the object's fail the read at its end, and a stream driven before
1206    /// it has them refuses to fetch anything at all.
1207    #[tokio::test]
1208    async fn a_resumed_read_holds_the_prefix_to_the_same_verdict() {
1209        let (_temp_dir, inner, content_store_id) = test_store();
1210        let store = CountingStore::new(inner, KeyPredicate::content_blob());
1211        let bytes = payload(2 * TEST_CHUNK_BYTES as usize);
1212        let content_ref = content_ref(&bytes);
1213        put_content_object(&store, &content_store_id, &content_ref, &bytes).await;
1214        let held = TEST_CHUNK_BYTES as usize;
1215
1216        store.reset();
1217        let mut unfed = open_stream_at(&store, &content_store_id, &content_ref, held as u64)
1218            .await
1219            .expect("open stream");
1220        let err = unfed.next_chunk().await.expect_err("prefix still owed");
1221        assert!(
1222            matches!(
1223                err,
1224                CoreError::ResumePrefixIncomplete {
1225                    start_offset,
1226                    folded: 0
1227                } if start_offset == held as u64
1228            ),
1229            "unexpected error: {err}"
1230        );
1231        assert_eq!(
1232            store.count(OperationClass::Read),
1233            0,
1234            "nothing is fetched until the stream has what it skipped"
1235        );
1236
1237        let mut wrong = open_stream_at(&store, &content_store_id, &content_ref, held as u64)
1238            .await
1239            .expect("open stream");
1240        wrong.fold_resumed_prefix(&vec![0u8; held]);
1241        let verdict = loop {
1242            match wrong.next_chunk().await {
1243                Ok(Some(_)) => continue,
1244                // A verified end is the only thing that reports `None`, so
1245                // this arm would mean bad bytes had been accepted.
1246                #[allow(clippy::panic, reason = "the failure this test exists to catch")]
1247                Ok(None) => panic!("a prefix that is not the object's verified"),
1248                Err(error) => break error,
1249            }
1250        };
1251        assert!(
1252            matches!(
1253                verdict,
1254                CoreError::DurableContent(
1255                    DurableContentValidationError::ContentChecksumMismatch { .. }
1256                )
1257            ),
1258            "a prefix that is not the object's fails the whole read: {verdict}"
1259        );
1260    }
1261
1262    /// An empty file has nothing to fetch and still verifies: the digest of
1263    /// no bytes is the digest its reference carries.
1264    #[tokio::test]
1265    async fn a_streamed_read_of_an_empty_object_verifies_without_fetching() {
1266        let (_temp_dir, inner, content_store_id) = test_store();
1267        let store = CountingStore::new(inner, KeyPredicate::content_blob());
1268        let content_ref = content_ref(b"");
1269        put_content_object(&store, &content_store_id, &content_ref, b"").await;
1270
1271        store.reset();
1272        let mut stream = open_stream(&store, &content_store_id, &content_ref)
1273            .await
1274            .expect("open stream");
1275        assert!(stream.next_chunk().await.expect("verified end").is_none());
1276        assert_eq!(
1277            store.count(OperationClass::Read),
1278            0,
1279            "an empty object needs no ranged read"
1280        );
1281    }
1282
1283    /// A reference whose only evidence is a checksum this build cannot
1284    /// recompute is refused before any byte moves, not after — a streamed
1285    /// read that discovered this at the end would already have handed
1286    /// unverifiable bytes to its caller.
1287    #[tokio::test]
1288    async fn a_streamed_read_refuses_a_reference_it_cannot_verify_before_reading() {
1289        let (_temp_dir, inner, content_store_id) = test_store();
1290        let store = CountingStore::new(inner, KeyPredicate::content_blob());
1291        let bytes = b"crc only";
1292        let mut content_ref = content_ref(bytes);
1293        content_ref.whole_file_sha256 = None;
1294        content_ref.storage_checksum = StorageChecksum {
1295            algorithm: ChecksumAlgorithm::Crc32c,
1296            value: "00000000".to_owned(),
1297        };
1298        put_content_object(&store, &content_store_id, &content_ref, bytes).await;
1299
1300        store.reset();
1301        let err = open_stream(&store, &content_store_id, &content_ref)
1302            .await
1303            .expect_err("unverifiable checksum");
1304        assert!(matches!(
1305            err,
1306            DurableContentValidationError::ContentChecksumUnverifiable { .. }
1307        ));
1308        assert_eq!(store.count(OperationClass::Read), 0);
1309    }
1310
1311    /// The same evidence the buffered read holds bytes to, folded chunk by
1312    /// chunk: a provider-assembled object carries only a CRC, and a streamed
1313    /// read verifies it rather than waving it through.
1314    #[tokio::test]
1315    async fn a_streamed_read_verifies_a_reference_whose_only_evidence_is_a_crc64nvme() {
1316        let (_temp_dir, store, content_store_id) = test_store();
1317        let bytes = payload(2 * TEST_CHUNK_BYTES as usize);
1318        let content_ref = ContentRef {
1319            kind: ContentRefKind::BlobV1,
1320            content_id: ContentId::generate(),
1321            size_bytes: bytes.len() as u64,
1322            storage_checksum: StorageChecksum::crc64nvme(&bytes),
1323            whole_file_sha256: None,
1324        };
1325        put_content_object(&store, &content_store_id, &content_ref, &bytes).await;
1326
1327        let mut stream = open_stream(&store, &content_store_id, &content_ref)
1328            .await
1329            .expect("open stream");
1330        let mut read = Vec::new();
1331        while let Some(chunk) = stream.next_chunk().await.expect("chunk") {
1332            read.extend_from_slice(&chunk);
1333        }
1334        assert_eq!(read, bytes);
1335    }
1336
1337    /// Bytes that disagree with the reference fail the read at the call that
1338    /// reports the end, after the chunks have been handed out. That is what
1339    /// streaming costs, and why a caller that installs a file installs it
1340    /// only once this call has returned.
1341    #[tokio::test]
1342    async fn a_streamed_read_rejects_an_object_that_does_not_match_its_reference() {
1343        let (_temp_dir, store, content_store_id) = test_store();
1344        let bytes = payload(2 * TEST_CHUNK_BYTES as usize);
1345        let expected = content_ref(&bytes);
1346        // Same id and same length, different bytes: only the digest can tell.
1347        let mut planted = bytes.clone();
1348        planted[0] ^= 0xff;
1349        let planted_ref = ContentRef::blob_v1(expected.content_id.clone(), &planted);
1350        put_content_object(&store, &content_store_id, &planted_ref, &planted).await;
1351
1352        let mut stream = open_stream(&store, &content_store_id, &expected)
1353            .await
1354            .expect("open stream");
1355        let mut chunks = 0;
1356        let err = loop {
1357            match stream.next_chunk().await {
1358                Ok(Some(_)) => chunks += 1,
1359                Ok(None) => break None,
1360                Err(err) => break Some(err),
1361            }
1362        }
1363        .expect("a mismatched object must not report a verified end");
1364        assert_eq!(chunks, 2, "the mismatch is reported after the last chunk");
1365        assert!(matches!(
1366            err,
1367            CoreError::DurableContent(
1368                DurableContentValidationError::ContentChecksumMismatch { .. }
1369            )
1370        ));
1371    }
1372
1373    /// An object that is not there fails when the stream is opened, so a
1374    /// caller learns it before it has written anything anywhere.
1375    #[tokio::test]
1376    async fn a_streamed_read_reports_a_missing_object_when_it_opens() {
1377        let (_temp_dir, store, content_store_id) = test_store();
1378        let content_ref = content_ref(b"never stored");
1379
1380        let err = open_stream(&store, &content_store_id, &content_ref)
1381            .await
1382            .expect_err("missing object");
1383        assert!(matches!(
1384            err,
1385            DurableContentValidationError::MissingContentObject { .. }
1386        ));
1387    }
1388
1389    /// An object longer or shorter than its reference claims is caught
1390    /// before any of it is handed out, by the same size check the buffered
1391    /// read makes over the bytes it downloaded.
1392    #[tokio::test]
1393    async fn a_streamed_read_rejects_an_object_of_the_wrong_length() {
1394        let (_temp_dir, store, content_store_id) = test_store();
1395        let bytes = payload(TEST_CHUNK_BYTES as usize + 1);
1396        let mut content_ref = content_ref(&bytes);
1397        put_content_object(&store, &content_store_id, &content_ref, &bytes).await;
1398        content_ref.size_bytes += 1;
1399
1400        let err = open_stream(&store, &content_store_id, &content_ref)
1401            .await
1402            .expect_err("length mismatch");
1403        assert!(matches!(
1404            err,
1405            DurableContentValidationError::ContentLengthMismatch { .. }
1406        ));
1407    }
1408
1409    fn test_store() -> (tempfile::TempDir, LocalFsStore, ContentStoreId) {
1410        let temp_dir = tempdir().expect("tempdir");
1411        let store = LocalFsStore::new(temp_dir.path()).expect("store");
1412        let content_store_id = ContentStoreId::parse("cs_00000000000000000000000000000001")
1413            .expect("valid content store id");
1414        (temp_dir, store, content_store_id)
1415    }
1416
1417    async fn put_content_object(
1418        store: &impl ObjectStore,
1419        content_store_id: &ContentStoreId,
1420        content_ref: &ContentRef,
1421        bytes: &[u8],
1422    ) {
1423        let key = content_blob(content_store_id.as_str(), &content_ref.content_id);
1424        store
1425            .put_if_absent(&key, Bytes::copy_from_slice(bytes))
1426            .await
1427            .expect("put content");
1428    }
1429}