1use 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
96pub 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
118pub 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
134pub(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
181pub(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
206pub(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
233pub const CONTENT_READ_CHUNK_BYTES: u64 = 8 * 1024 * 1024;
242
243pub struct FileContentStream<S> {
264 store: S,
265 entry: AuthoritativePathEntry,
266 object_key: String,
267 content_ref: ContentRef,
268 chunk_bytes: NonZeroU64,
269 next_offset: u64,
271 resumed_from: u64,
275 prefix_folded: u64,
279 digest: StreamingChecksum,
281 expected: StorageChecksum,
283 completion: Option<Result<(), DurableContentValidationError>>,
288}
289
290impl<S: ObjectStore> FileContentStream<S> {
291 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 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 pub fn entry(&self) -> &AuthoritativePathEntry {
348 &self.entry
349 }
350
351 pub fn size_bytes(&self) -> u64 {
353 self.content_ref.size_bytes
354 }
355
356 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 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 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 fn verify_complete(&mut self) -> Result<(), DurableContentValidationError> {
448 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
501fn 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
552fn 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
569async 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#[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
621pub(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#[derive(Debug, Clone, PartialEq, Eq)]
647pub(crate) struct StagedStream {
648 pub content_ref: ContentRef,
652 pub already_present: bool,
658}
659
660pub(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 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
721pub(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#[derive(Debug, Default)]
746struct StreamedPayload {
747 digest: Sha256,
748 size_bytes: u64,
749}
750
751pub(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 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 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 #[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 #[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 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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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}