Skip to main content

loonfs_api/
content.rs

1//! [`ContentRef`]: the durable reference a file revision points at, naming
2//! one immutable content object and carrying the integrity evidence for it.
3
4use crate::hex::hex_encode_bytes;
5use crate::ids::ContentId;
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256 as Sha2Sha256};
8use std::fmt;
9use thiserror::Error;
10
11/// Kind of content reference.
12///
13/// Serializes as a plain string (`"blob_v1"`). Kinds unknown to this build
14/// decode as [`ContentRefKind::Unsupported`] carrying the original string,
15/// and re-serialize to that same string — so a reader that merely relays or
16/// rewrites rows it does not fully understand can never destroy a newer
17/// kind. Writers must not *create* references with an unsupported kind;
18/// commit validation rejects them (format spec, "Validation and logical commits").
19#[derive(Debug, Clone, PartialEq, Eq, Hash)]
20pub enum ContentRefKind {
21    /// One immutable content object, addressed by its random content id.
22    BlobV1,
23    /// A content kind unknown to this build, preserved verbatim.
24    Unsupported(String),
25}
26
27impl ContentRefKind {
28    const BLOB_V1: &'static str = "blob_v1";
29
30    /// Returns the frozen wire spelling, including an unknown spelling preserved by a reader.
31    pub fn as_str(&self) -> &str {
32        match self {
33            Self::BlobV1 => Self::BLOB_V1,
34            Self::Unsupported(other) => other,
35        }
36    }
37}
38
39impl fmt::Display for ContentRefKind {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        f.write_str(self.as_str())
42    }
43}
44
45impl Serialize for ContentRefKind {
46    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
47    where
48        S: serde::Serializer,
49    {
50        serializer.serialize_str(self.as_str())
51    }
52}
53
54impl<'de> Deserialize<'de> for ContentRefKind {
55    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
56    where
57        D: serde::Deserializer<'de>,
58    {
59        let value = String::deserialize(deserializer)?;
60        Ok(match value.as_str() {
61            Self::BLOB_V1 => Self::BlobV1,
62            _ => Self::Unsupported(value),
63        })
64    }
65}
66
67/// Algorithm of a stored full-object checksum.
68///
69/// Every algorithm here covers the complete object. There is deliberately no
70/// checksum-*type* field: full-object coverage is an invariant of this
71/// format, established when the object is written, never read back from a
72/// provider. (Cloudflare R2 never reports `x-amz-checksum-type` at all, so a
73/// type read back would be unavailable exactly where it would matter.)
74///
75/// [`ChecksumAlgorithm::Sha256`] and [`ChecksumAlgorithm::Crc64nvme`] both
76/// have producers: every path that moves bytes through LoonFS hashes them,
77/// and direct multipart upload carries the CRC-64/NVME the S3-compatible
78/// providers compute over the assembled object. `Crc32c` decodes and
79/// round-trips without a producer.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
81#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
82#[serde(rename_all = "snake_case")]
83pub enum ChecksumAlgorithm {
84    /// SHA-256 over the complete object.
85    Sha256,
86    /// CRC-64/NVME over the complete object.
87    Crc64nvme,
88    /// CRC-32C over the complete object.
89    Crc32c,
90}
91
92impl ChecksumAlgorithm {
93    /// Returns the frozen wire spelling.
94    pub fn as_str(self) -> &'static str {
95        match self {
96            Self::Sha256 => "sha256",
97            Self::Crc64nvme => "crc64nvme",
98            Self::Crc32c => "crc32c",
99        }
100    }
101
102    /// Returns the raw checksum width in bytes.
103    pub fn value_bytes(self) -> usize {
104        match self {
105            Self::Sha256 => 32,
106            Self::Crc64nvme => 8,
107            Self::Crc32c => 4,
108        }
109    }
110}
111
112impl fmt::Display for ChecksumAlgorithm {
113    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114        f.write_str(self.as_str())
115    }
116}
117
118/// A checksum computed over the complete bytes of one content object.
119#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
120#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
121#[serde(deny_unknown_fields)]
122pub struct StorageChecksum {
123    /// Algorithm that produced `value`.
124    pub algorithm: ChecksumAlgorithm,
125    /// Lowercase hex of the raw checksum bytes.
126    ///
127    /// The algorithm is its own field, so the value carries no prefix.
128    /// Provider APIs that report base64 are converted at the adapter.
129    pub value: String,
130}
131
132impl StorageChecksum {
133    /// Builds the SHA-256 storage checksum for these complete bytes.
134    pub fn sha256(bytes: &[u8]) -> Self {
135        Self {
136            algorithm: ChecksumAlgorithm::Sha256,
137            value: hex_encode_bytes(&Sha2Sha256::digest(bytes)),
138        }
139    }
140
141    /// Builds the CRC-64/NVME storage checksum for these complete bytes.
142    pub fn crc64nvme(bytes: &[u8]) -> Self {
143        let mut digest = Crc64Nvme::new();
144        digest.update(bytes);
145        digest.finish()
146    }
147
148    /// Reports whether these bytes produce this exact checksum.
149    ///
150    /// `None` means the algorithm has no implementation here, which is a
151    /// refusal to verify rather than a verification: a caller must never
152    /// read it as agreement.
153    pub fn matches(&self, bytes: &[u8]) -> Option<bool> {
154        let recomputed = match self.algorithm {
155            ChecksumAlgorithm::Sha256 => Self::sha256(bytes),
156            ChecksumAlgorithm::Crc64nvme => Self::crc64nvme(bytes),
157            ChecksumAlgorithm::Crc32c => return None,
158        };
159        Some(recomputed.value == self.value)
160    }
161}
162
163/// One full-object checksum folded over a payload delivered in pieces.
164///
165/// This is the streaming form of [`StorageChecksum::matches`], for a reader
166/// that verifies an object it never holds whole. The two agree about what
167/// this build can recompute: [`StreamingChecksum::for_algorithm`] answers
168/// `None` for exactly the algorithms `matches` refuses to judge, so an
169/// unverifiable checksum is refused before any bytes move rather than after.
170#[derive(Debug)]
171pub enum StreamingChecksum {
172    /// SHA-256 folded over the payload.
173    Sha256(Sha256),
174    /// CRC-64/NVME folded over the payload.
175    Crc64nvme(Crc64Nvme),
176}
177
178impl StreamingChecksum {
179    /// Starts an empty digest for `algorithm`, or `None` when this build
180    /// cannot recompute that algorithm.
181    pub fn for_algorithm(algorithm: ChecksumAlgorithm) -> Option<Self> {
182        match algorithm {
183            ChecksumAlgorithm::Sha256 => Some(Self::Sha256(Sha256::new())),
184            ChecksumAlgorithm::Crc64nvme => Some(Self::Crc64nvme(Crc64Nvme::new())),
185            ChecksumAlgorithm::Crc32c => None,
186        }
187    }
188
189    /// Folds the next piece of the payload in, in order.
190    pub fn update(&mut self, bytes: &[u8]) {
191        match self {
192            Self::Sha256(digest) => digest.update(bytes),
193            Self::Crc64nvme(digest) => digest.update(bytes),
194        }
195    }
196
197    /// Closes the digest over everything fed so far.
198    pub fn finish(self) -> StorageChecksum {
199        match self {
200            Self::Sha256(digest) => digest.finish(),
201            Self::Crc64nvme(digest) => digest.finish(),
202        }
203    }
204}
205
206/// CRC-64/NVME over a payload delivered in pieces.
207///
208/// A direct multipart upload needs this digest twice over the same bytes:
209/// once per part, for the header the provider enforces on the way in, and
210/// once over the whole stream, for the reference completion verifies. Parts
211/// fed in order produce both without the object ever being held whole.
212#[derive(Default)]
213pub struct Crc64Nvme {
214    digest: crc64fast_nvme::Digest,
215}
216
217impl Crc64Nvme {
218    /// Starts an empty digest.
219    pub fn new() -> Self {
220        Self {
221            digest: crc64fast_nvme::Digest::new(),
222        }
223    }
224
225    /// Folds the next piece of the payload in, in order.
226    pub fn update(&mut self, bytes: &[u8]) {
227        self.digest.write(bytes);
228    }
229
230    /// Closes the digest over everything fed so far.
231    ///
232    /// The value is the big-endian spelling of the 64-bit result, which is
233    /// what the raw checksum bytes are on the wire and therefore what the
234    /// hex here has to be.
235    pub fn finish(self) -> StorageChecksum {
236        StorageChecksum {
237            algorithm: ChecksumAlgorithm::Crc64nvme,
238            value: hex_encode_bytes(&self.digest.sum64().to_be_bytes()),
239        }
240    }
241}
242
243impl fmt::Debug for Crc64Nvme {
244    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
245        f.debug_struct("Crc64Nvme").finish_non_exhaustive()
246    }
247}
248
249/// SHA-256 over a payload delivered in pieces.
250///
251/// The proxied write path folds this over the request body as it forwards
252/// it to object storage, so a reference's trusted whole-file digest exists
253/// without the payload ever being held whole. Pieces must be fed in order.
254#[derive(Default)]
255pub struct Sha256 {
256    digest: Sha2Sha256,
257}
258
259impl Sha256 {
260    /// Starts an empty digest.
261    pub fn new() -> Self {
262        Self {
263            digest: Sha2Sha256::new(),
264        }
265    }
266
267    /// Folds the next piece of the payload in, in order.
268    pub fn update(&mut self, bytes: &[u8]) {
269        self.digest.update(bytes);
270    }
271
272    /// Closes the digest over everything fed so far.
273    pub fn finish(self) -> StorageChecksum {
274        StorageChecksum {
275            algorithm: ChecksumAlgorithm::Sha256,
276            value: hex_encode_bytes(&self.digest.finalize()),
277        }
278    }
279}
280
281impl fmt::Debug for Sha256 {
282    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
283        f.debug_struct("Sha256").finish_non_exhaustive()
284    }
285}
286
287/// Describes why a content reference cannot be part of a durable commit.
288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Error)]
289pub enum ContentRefValidationError {
290    /// The reference names a content strategy this build cannot write.
291    #[error("unsupported content ref kind `{kind}`")]
292    UnsupportedKind {
293        /// Kind spelling carried by the rejected reference.
294        kind: String,
295    },
296    /// A checksum value was not the algorithm's width in lowercase hex.
297    #[error("invalid {field} for algorithm `{algorithm}`: {reason}")]
298    InvalidChecksum {
299        /// Reference field that carried the rejected value.
300        field: String,
301        /// Algorithm whose width and alphabet the value violated.
302        algorithm: ChecksumAlgorithm,
303        /// Specific rule the value broke.
304        reason: String,
305    },
306}
307
308/// Pointer to one immutable content object.
309///
310/// `content_id` is identity — *which* object — and the checksums are
311/// evidence about its bytes. Separating the two is what lets the final
312/// object key exist before the first byte is read.
313///
314/// A `ContentRef` is safe to publish only after the referenced bytes are
315/// durable in the namespace's content store.
316#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
317#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
318#[serde(deny_unknown_fields)]
319pub struct ContentRef {
320    /// Content strategy used by the referenced object.
321    #[cfg_attr(feature = "openapi", schema(value_type = String))]
322    pub kind: ContentRefKind,
323    /// Immutable identity of the referenced object.
324    pub content_id: ContentId,
325    /// Complete byte length of the referenced content.
326    pub size_bytes: u64,
327    /// Mandatory checksum over the complete object, used to verify the
328    /// stored bytes against this reference without downloading them.
329    pub storage_checksum: StorageChecksum,
330    /// SHA-256 over the complete payload, lowercase hex, when a trusted
331    /// party computed it.
332    ///
333    /// Present means the LoonFS write path hashed the whole stream itself,
334    /// or a provider validated a signed whole-object SHA-256 on the write.
335    /// There are no client-claimed digests: absent means nobody trustworthy
336    /// hashed these bytes, never "the client did not tell us".
337    #[serde(default, skip_serializing_if = "Option::is_none")]
338    pub whole_file_sha256: Option<String>,
339}
340
341impl ContentRef {
342    /// Builds a reference to a freshly minted content object holding these bytes.
343    ///
344    /// Every caller of this constructor moves the bytes through the LoonFS
345    /// write path, so the whole-file SHA-256 is trusted by construction.
346    pub fn blob_v1(content_id: ContentId, bytes: &[u8]) -> Self {
347        let storage_checksum = StorageChecksum::sha256(bytes);
348        Self {
349            kind: ContentRefKind::BlobV1,
350            content_id,
351            size_bytes: bytes.len() as u64,
352            whole_file_sha256: Some(storage_checksum.value.clone()),
353            storage_checksum,
354        }
355    }
356
357    /// Builds a reference from a digest folded over the payload as it was
358    /// written, for a write that never held the whole payload at once.
359    ///
360    /// Taking the digest itself rather than a checksum value is what keeps
361    /// the provenance rule structural: the only way to reach this
362    /// constructor is to have hashed the bytes here, on the LoonFS write
363    /// path, which is exactly what `whole_file_sha256` claims.
364    pub fn blob_v1_streamed(content_id: ContentId, size_bytes: u64, digest: Sha256) -> Self {
365        let storage_checksum = digest.finish();
366        Self {
367            kind: ContentRefKind::BlobV1,
368            content_id,
369            size_bytes,
370            whole_file_sha256: Some(storage_checksum.value.clone()),
371            storage_checksum,
372        }
373    }
374
375    /// Reports whether the reference is well formed enough to publish.
376    ///
377    /// This is a shape check on the reference itself; proving that the
378    /// object exists and matches is the storage layer's job.
379    pub fn validate(&self) -> Result<(), ContentRefValidationError> {
380        if self.kind != ContentRefKind::BlobV1 {
381            return Err(ContentRefValidationError::UnsupportedKind {
382                kind: self.kind.as_str().to_owned(),
383            });
384        }
385        validate_checksum_value(
386            "storage_checksum",
387            self.storage_checksum.algorithm,
388            &self.storage_checksum.value,
389        )?;
390        if let Some(whole_file_sha256) = &self.whole_file_sha256 {
391            validate_checksum_value(
392                "whole_file_sha256",
393                ChecksumAlgorithm::Sha256,
394                whole_file_sha256,
395            )?;
396        }
397        Ok(())
398    }
399}
400
401fn validate_checksum_value(
402    field: &str,
403    algorithm: ChecksumAlgorithm,
404    value: &str,
405) -> Result<(), ContentRefValidationError> {
406    let expected_len = algorithm.value_bytes() * 2;
407    if value.len() != expected_len {
408        return Err(ContentRefValidationError::InvalidChecksum {
409            field: field.to_owned(),
410            algorithm,
411            reason: format!("must be {expected_len} hex characters, got {}", value.len()),
412        });
413    }
414    if !value
415        .bytes()
416        .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
417    {
418        return Err(ContentRefValidationError::InvalidChecksum {
419            field: field.to_owned(),
420            algorithm,
421            reason: "must be lowercase hex".to_owned(),
422        });
423    }
424    Ok(())
425}
426
427#[cfg(test)]
428mod tests {
429    use super::{
430        ChecksumAlgorithm, ContentRef, ContentRefKind, ContentRefValidationError, Crc64Nvme,
431        StorageChecksum, StreamingChecksum,
432    };
433    use crate::ids::ContentId;
434
435    fn content_id() -> ContentId {
436        ContentId::parse("con_0123456789abcdef0123456789abcdef").expect("valid content id")
437    }
438
439    #[test]
440    fn known_kind_round_trips_as_snake_case_string() {
441        let encoded = serde_json::to_string(&ContentRefKind::BlobV1).expect("encode");
442        assert_eq!(encoded, "\"blob_v1\"");
443        let decoded: ContentRefKind = serde_json::from_str(&encoded).expect("decode");
444        assert_eq!(decoded, ContentRefKind::BlobV1);
445    }
446
447    #[test]
448    fn unknown_kind_is_preserved_verbatim_through_a_round_trip() {
449        let decoded: ContentRefKind =
450            serde_json::from_str("\"sparse_file_v9\"").expect("decode unknown kind");
451        assert_eq!(
452            decoded,
453            ContentRefKind::Unsupported("sparse_file_v9".to_owned())
454        );
455        let reencoded = serde_json::to_string(&decoded).expect("encode unknown kind");
456        assert_eq!(reencoded, "\"sparse_file_v9\"");
457    }
458
459    #[test]
460    fn every_checksum_algorithm_round_trips() {
461        for (algorithm, wire) in [
462            (ChecksumAlgorithm::Sha256, "\"sha256\""),
463            (ChecksumAlgorithm::Crc64nvme, "\"crc64nvme\""),
464            (ChecksumAlgorithm::Crc32c, "\"crc32c\""),
465        ] {
466            let encoded = serde_json::to_string(&algorithm).expect("encode algorithm");
467            assert_eq!(encoded, wire);
468            let decoded: ChecksumAlgorithm =
469                serde_json::from_str(&encoded).expect("decode algorithm");
470            assert_eq!(decoded, algorithm);
471        }
472    }
473
474    #[test]
475    fn a_content_ref_rejects_unknown_fields() {
476        let json = r#"{
477            "kind": "blob_v1",
478            "content_id": "con_0123456789abcdef0123456789abcdef",
479            "size_bytes": 5,
480            "storage_checksum": {"algorithm": "sha256", "value": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"},
481            "checksum_type": "full_object"
482        }"#;
483        assert!(serde_json::from_str::<ContentRef>(json).is_err());
484    }
485
486    #[test]
487    fn a_produced_reference_carries_a_trusted_whole_file_sha256() {
488        let content_ref = ContentRef::blob_v1(content_id(), b"hello");
489
490        assert_eq!(content_ref.kind, ContentRefKind::BlobV1);
491        assert_eq!(content_ref.size_bytes, 5);
492        assert_eq!(
493            content_ref.storage_checksum.algorithm,
494            ChecksumAlgorithm::Sha256
495        );
496        assert_eq!(
497            content_ref.whole_file_sha256.as_deref(),
498            Some(content_ref.storage_checksum.value.as_str())
499        );
500        content_ref.validate().expect("produced refs validate");
501    }
502
503    #[test]
504    fn validation_rejects_unsupported_kinds_and_malformed_checksums() {
505        let mut content_ref = ContentRef::blob_v1(content_id(), b"hello");
506        content_ref.kind = ContentRefKind::Unsupported("sparse_file_v9".to_owned());
507        assert!(matches!(
508            content_ref.validate(),
509            Err(ContentRefValidationError::UnsupportedKind { .. })
510        ));
511
512        let mut content_ref = ContentRef::blob_v1(content_id(), b"hello");
513        content_ref.storage_checksum = StorageChecksum {
514            algorithm: ChecksumAlgorithm::Crc64nvme,
515            value: content_ref.storage_checksum.value.clone(),
516        };
517        assert!(matches!(
518            content_ref.validate(),
519            Err(ContentRefValidationError::InvalidChecksum { .. })
520        ));
521
522        let mut content_ref = ContentRef::blob_v1(content_id(), b"hello");
523        content_ref.whole_file_sha256 = Some(content_ref.storage_checksum.value.to_uppercase());
524        assert!(matches!(
525            content_ref.validate(),
526            Err(ContentRefValidationError::InvalidChecksum { .. })
527        ));
528    }
529
530    /// The catalog check value for CRC-64/NVME. This is the one thing that
531    /// has to agree with the provider bit for bit: a completion compares our
532    /// value against the one S3 computed over the assembled object, so a
533    /// wrong polynomial or byte order would fail every multipart upload.
534    #[test]
535    fn crc64nvme_matches_its_catalog_check_value() {
536        assert_eq!(
537            StorageChecksum::crc64nvme(b"123456789").value,
538            "ae8b14860a799888"
539        );
540        assert_eq!(
541            StorageChecksum::crc64nvme(b"").value,
542            "0000000000000000",
543            "the empty payload is the identity"
544        );
545    }
546
547    /// The streaming form exists so parts can be hashed on the way past
548    /// without the whole object ever being held, so it must agree with the
549    /// one-shot form over the same bytes.
550    #[test]
551    fn a_streamed_crc64nvme_equals_the_whole_payload_at_once() {
552        let payload: Vec<u8> = (0..4096u32).map(|byte| byte as u8).collect();
553        let mut streamed = Crc64Nvme::new();
554        for chunk in payload.chunks(97) {
555            streamed.update(chunk);
556        }
557
558        assert_eq!(streamed.finish(), StorageChecksum::crc64nvme(&payload));
559    }
560
561    /// A verifying reader folds the same checksum the one-shot check
562    /// computes, and refuses the same algorithms it refuses — a reader that
563    /// disagreed with [`StorageChecksum::matches`] would accept or reject
564    /// objects the buffered read would not.
565    #[test]
566    fn a_streamed_checksum_agrees_with_the_whole_payload_at_once() {
567        let payload: Vec<u8> = (0..4096u32).map(|byte| byte as u8).collect();
568        for expected in [
569            StorageChecksum::sha256(&payload),
570            StorageChecksum::crc64nvme(&payload),
571        ] {
572            let mut streaming = StreamingChecksum::for_algorithm(expected.algorithm)
573                .expect("a producing algorithm folds");
574            for chunk in payload.chunks(97) {
575                streaming.update(chunk);
576            }
577            assert_eq!(streaming.finish(), expected);
578        }
579        assert!(StreamingChecksum::for_algorithm(ChecksumAlgorithm::Crc32c).is_none());
580    }
581
582    /// A checksum this build cannot recompute must answer "cannot tell",
583    /// never "matches".
584    #[test]
585    fn checksum_matching_refuses_rather_than_agrees_when_it_cannot_recompute() {
586        assert_eq!(
587            StorageChecksum::sha256(b"hello").matches(b"hello"),
588            Some(true)
589        );
590        assert_eq!(
591            StorageChecksum::sha256(b"hello").matches(b"other"),
592            Some(false)
593        );
594        assert_eq!(
595            StorageChecksum::crc64nvme(b"hello").matches(b"hello"),
596            Some(true)
597        );
598        assert_eq!(
599            StorageChecksum {
600                algorithm: ChecksumAlgorithm::Crc32c,
601                value: "00000000".to_owned(),
602            }
603            .matches(b"hello"),
604            None
605        );
606    }
607
608    #[test]
609    fn a_crc_only_reference_round_trips_without_a_whole_file_sha256() {
610        let content_ref = ContentRef {
611            kind: ContentRefKind::BlobV1,
612            content_id: content_id(),
613            size_bytes: 11_534_336,
614            storage_checksum: StorageChecksum {
615                algorithm: ChecksumAlgorithm::Crc64nvme,
616                value: "bbb7305bdf118bcb".to_owned(),
617            },
618            whole_file_sha256: None,
619        };
620        content_ref.validate().expect("crc-only refs are valid");
621
622        let encoded = serde_json::to_string(&content_ref).expect("encode");
623        assert!(!encoded.contains("whole_file_sha256"));
624        let decoded: ContentRef = serde_json::from_str(&encoded).expect("decode");
625        assert_eq!(decoded, content_ref);
626    }
627}