Skip to main content

loonfs_api/
content.rs

1//! Immutable content references and their checksums.
2
3use crate::hex::{hex_encode_bytes, is_lower_hex_byte};
4use crate::ids::{ContentId, NamespaceId};
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256 as Sha2Sha256};
7use std::fmt;
8use thiserror::Error;
9
10/// A supported content reference kind serialized as a string.
11#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
12#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
13#[serde(rename_all = "snake_case")]
14pub enum ContentRefKind {
15    /// One immutable content object, addressed by its random content id.
16    BlobV1,
17}
18
19impl ContentRefKind {
20    /// Returns the frozen wire spelling.
21    pub fn as_str(&self) -> &str {
22        match self {
23            Self::BlobV1 => "blob_v1",
24        }
25    }
26}
27
28impl fmt::Display for ContentRefKind {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        f.write_str(self.as_str())
31    }
32}
33
34/// A supported checksum algorithm.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
36#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
37#[serde(rename_all = "snake_case")]
38pub enum ChecksumAlgorithm {
39    /// SHA-256.
40    Sha256,
41    /// CRC-64/NVME.
42    Crc64nvme,
43    /// CRC-32C.
44    Crc32c,
45}
46
47impl ChecksumAlgorithm {
48    /// Returns the frozen wire spelling.
49    pub fn as_str(self) -> &'static str {
50        match self {
51            Self::Sha256 => "sha256",
52            Self::Crc64nvme => "crc64nvme",
53            Self::Crc32c => "crc32c",
54        }
55    }
56
57    /// Returns the raw checksum width in bytes.
58    pub fn value_bytes(self) -> usize {
59        match self {
60            Self::Sha256 => 32,
61            Self::Crc64nvme => 8,
62            Self::Crc32c => 4,
63        }
64    }
65}
66
67impl fmt::Display for ChecksumAlgorithm {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        f.write_str(self.as_str())
70    }
71}
72
73/// A checksum algorithm and its canonical lowercase hexadecimal value.
74// This type also appears in request bodies, so it rejects unknown fields in
75// every context. Add new algorithms instead of new fields. This is not
76// rustdoc because it describes storage behavior, not the public API.
77#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
78#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
79#[serde(deny_unknown_fields)]
80pub struct Checksum {
81    /// Algorithm that produced `value`.
82    pub algorithm: ChecksumAlgorithm,
83    /// The canonical lowercase hexadecimal checksum without a prefix.
84    pub value: String,
85}
86
87impl Checksum {
88    /// Builds the `algorithm` checksum for these complete bytes.
89    ///
90    /// The one-shot forms below are this with the algorithm spelled out, so
91    /// a payload held whole and one delivered in pieces cannot drift: both
92    /// close the same digest.
93    pub fn compute(algorithm: ChecksumAlgorithm, bytes: &[u8]) -> Self {
94        let mut digest = StreamingChecksum::for_algorithm(algorithm);
95        digest.update(bytes);
96        digest.finish()
97    }
98
99    /// Builds the SHA-256 checksum for these bytes.
100    pub fn sha256(bytes: &[u8]) -> Self {
101        Self::compute(ChecksumAlgorithm::Sha256, bytes)
102    }
103
104    /// Builds the CRC-64/NVME checksum for these bytes.
105    pub fn crc64nvme(bytes: &[u8]) -> Self {
106        Self::compute(ChecksumAlgorithm::Crc64nvme, bytes)
107    }
108
109    /// Builds the CRC-32C checksum for these bytes.
110    pub fn crc32c(bytes: &[u8]) -> Self {
111        Self::compute(ChecksumAlgorithm::Crc32c, bytes)
112    }
113
114    /// Reports whether these bytes produce this exact checksum.
115    pub fn matches(&self, bytes: &[u8]) -> bool {
116        Self::compute(self.algorithm, bytes).value == self.value
117    }
118
119    /// Validates the exact width and lowercase-hex alphabet for `algorithm`.
120    pub fn validate(&self) -> Result<(), ChecksumValidationError> {
121        let expected_len = self.algorithm.value_bytes() * 2;
122        if self.value.len() != expected_len {
123            return Err(ChecksumValidationError::InvalidWidth {
124                algorithm: self.algorithm,
125                expected_len,
126                actual_len: self.value.len(),
127            });
128        }
129        if !self.value.bytes().all(is_lower_hex_byte) {
130            return Err(ChecksumValidationError::InvalidAlphabet {
131                algorithm: self.algorithm,
132            });
133        }
134        Ok(())
135    }
136}
137
138/// Describes why a checksum is not in its canonical wire form.
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Error)]
140pub enum ChecksumValidationError {
141    /// The checksum value does not have the exact width for its algorithm.
142    #[error(
143        "checksum for algorithm `{algorithm}` must be {expected_len} hex characters, got {actual_len}"
144    )]
145    InvalidWidth {
146        /// Algorithm whose checksum width was required.
147        algorithm: ChecksumAlgorithm,
148        /// Required number of hexadecimal characters.
149        expected_len: usize,
150        /// Number of characters supplied.
151        actual_len: usize,
152    },
153    /// The checksum value contains a character outside lowercase hexadecimal.
154    #[error("checksum for algorithm `{algorithm}` must be lowercase hex")]
155    InvalidAlphabet {
156        /// Algorithm whose checksum value was rejected.
157        algorithm: ChecksumAlgorithm,
158    },
159}
160
161/// An incremental checksum for streamed reads and writes.
162#[derive(Debug)]
163pub enum StreamingChecksum {
164    /// SHA-256 folded over the payload.
165    Sha256(Sha256),
166    /// CRC-64/NVME folded over the payload.
167    Crc64nvme(Crc64Nvme),
168    /// CRC-32C folded over the payload.
169    Crc32c(Crc32c),
170}
171
172impl StreamingChecksum {
173    /// Starts an empty digest for `algorithm`.
174    pub fn for_algorithm(algorithm: ChecksumAlgorithm) -> Self {
175        match algorithm {
176            ChecksumAlgorithm::Sha256 => Self::Sha256(Sha256::new()),
177            ChecksumAlgorithm::Crc64nvme => Self::Crc64nvme(Crc64Nvme::new()),
178            ChecksumAlgorithm::Crc32c => Self::Crc32c(Crc32c::new()),
179        }
180    }
181
182    /// Folds the next piece of the payload in, in order.
183    pub fn update(&mut self, bytes: &[u8]) {
184        match self {
185            Self::Sha256(digest) => digest.update(bytes),
186            Self::Crc64nvme(digest) => digest.update(bytes),
187            Self::Crc32c(digest) => digest.update(bytes),
188        }
189    }
190
191    /// Closes the digest over everything fed so far.
192    pub fn finish(self) -> Checksum {
193        match self {
194            Self::Sha256(digest) => digest.finish(),
195            Self::Crc64nvme(digest) => digest.finish(),
196            Self::Crc32c(digest) => digest.finish(),
197        }
198    }
199}
200
201/// An incremental CRC-64/NVME checksum.
202#[derive(Default)]
203pub struct Crc64Nvme {
204    digest: crc64fast_nvme::Digest,
205}
206
207impl Crc64Nvme {
208    /// Starts an empty digest.
209    pub fn new() -> Self {
210        Self {
211            digest: crc64fast_nvme::Digest::new(),
212        }
213    }
214
215    /// Folds the next piece of the payload in, in order.
216    pub fn update(&mut self, bytes: &[u8]) {
217        self.digest.write(bytes);
218    }
219
220    /// Closes the digest over everything fed so far.
221    ///
222    /// The value is the big-endian spelling of the 64-bit result, which is
223    /// what the raw checksum bytes are on the wire and therefore what the
224    /// hex here has to be.
225    pub fn finish(self) -> Checksum {
226        Checksum {
227            algorithm: ChecksumAlgorithm::Crc64nvme,
228            value: hex_encode_bytes(&self.digest.sum64().to_be_bytes()),
229        }
230    }
231}
232
233impl fmt::Debug for Crc64Nvme {
234    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
235        f.debug_struct("Crc64Nvme").finish_non_exhaustive()
236    }
237}
238
239/// An incremental CRC-32C checksum.
240#[derive(Default)]
241pub struct Crc32c {
242    crc: u32,
243}
244
245impl Crc32c {
246    /// Starts an empty digest.
247    pub fn new() -> Self {
248        Self { crc: 0 }
249    }
250
251    /// Folds the next piece of the payload in, in order.
252    pub fn update(&mut self, bytes: &[u8]) {
253        self.crc = crc32c::crc32c_append(self.crc, bytes);
254    }
255
256    /// Closes the digest over everything fed so far.
257    ///
258    /// The value is the big-endian spelling of the 32-bit result, which is
259    /// what the raw checksum bytes are on the wire and therefore what the
260    /// hex here has to be.
261    pub fn finish(self) -> Checksum {
262        Checksum {
263            algorithm: ChecksumAlgorithm::Crc32c,
264            value: hex_encode_bytes(&self.crc.to_be_bytes()),
265        }
266    }
267}
268
269impl fmt::Debug for Crc32c {
270    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
271        f.debug_struct("Crc32c").finish_non_exhaustive()
272    }
273}
274
275/// An incremental SHA-256 checksum.
276#[derive(Default)]
277pub struct Sha256 {
278    digest: Sha2Sha256,
279}
280
281impl Sha256 {
282    /// Starts an empty digest.
283    pub fn new() -> Self {
284        Self {
285            digest: Sha2Sha256::new(),
286        }
287    }
288
289    /// Folds the next piece of the payload in, in order.
290    pub fn update(&mut self, bytes: &[u8]) {
291        self.digest.update(bytes);
292    }
293
294    /// Closes the digest over everything fed so far.
295    pub fn finish(self) -> Checksum {
296        Checksum {
297            algorithm: ChecksumAlgorithm::Sha256,
298            value: hex_encode_bytes(&self.digest.finalize()),
299        }
300    }
301}
302
303impl fmt::Debug for Sha256 {
304    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
305        f.debug_struct("Sha256").finish_non_exhaustive()
306    }
307}
308
309/// Describes why a content reference cannot be part of a durable commit.
310#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Error)]
311pub enum ContentRefValidationError {
312    /// The checksum is not in the algorithm's canonical form.
313    #[error("invalid content ref checksum: {0}")]
314    InvalidChecksum(ChecksumValidationError),
315}
316
317/// A reference to one immutable content object.
318///
319/// The object must be durable before the reference is published.
320// Request bodies and durable records share this type, so it rejects unknown
321// fields in every context. After release, new content kinds, not new fields.
322#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
323#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
324#[serde(deny_unknown_fields)]
325pub struct ContentRef {
326    /// Content strategy used by the referenced object.
327    pub kind: ContentRefKind,
328    /// Namespace that originally wrote the bytes.
329    pub owner_namespace_id: NamespaceId,
330    /// Immutable identity of the referenced object.
331    pub content_id: ContentId,
332    /// Complete byte length of the referenced content.
333    pub size_bytes: u64,
334    /// Mandatory checksum over the complete object.
335    pub checksum: Checksum,
336}
337
338impl ContentRef {
339    /// Builds a reference to a freshly minted content object holding these bytes.
340    ///
341    /// Every caller of this constructor moves the bytes through the LoonFS
342    /// write path, so the checksum is trusted by construction.
343    pub fn blob_v1(owner_namespace_id: NamespaceId, content_id: ContentId, bytes: &[u8]) -> Self {
344        Self {
345            kind: ContentRefKind::BlobV1,
346            owner_namespace_id,
347            content_id,
348            size_bytes: bytes.len() as u64,
349            checksum: Checksum::sha256(bytes),
350        }
351    }
352
353    /// Builds a content reference from a SHA-256 computed while streaming the
354    /// payload.
355    ///
356    /// Accepting the digest object, rather than an arbitrary checksum string,
357    /// ensures that the checksum came from the LoonFS write path.
358    pub fn blob_v1_streamed(
359        owner_namespace_id: NamespaceId,
360        content_id: ContentId,
361        size_bytes: u64,
362        digest: Sha256,
363    ) -> Self {
364        Self {
365            kind: ContentRefKind::BlobV1,
366            owner_namespace_id,
367            content_id,
368            size_bytes,
369            checksum: digest.finish(),
370        }
371    }
372
373    /// Reports whether the reference is well formed enough to publish.
374    ///
375    /// This is a shape check on the reference itself; proving that the
376    /// object exists and matches is the storage layer's job.
377    pub fn validate(&self) -> Result<(), ContentRefValidationError> {
378        self.checksum
379            .validate()
380            .map_err(ContentRefValidationError::InvalidChecksum)?;
381        Ok(())
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use super::{
388        Checksum, ChecksumAlgorithm, ChecksumValidationError, ContentRef, ContentRefKind,
389        ContentRefValidationError, StreamingChecksum,
390    };
391    use crate::ids::ContentId;
392
393    fn content_id() -> ContentId {
394        ContentId::parse("con_0123456789abcdef0123456789abcdef").expect("valid content id")
395    }
396
397    #[test]
398    fn known_kind_round_trips_as_snake_case_string() {
399        let encoded = serde_json::to_string(&ContentRefKind::BlobV1).expect("encode");
400        assert_eq!(encoded, "\"blob_v1\"");
401        let decoded: ContentRefKind = serde_json::from_str(&encoded).expect("decode");
402        assert_eq!(decoded, ContentRefKind::BlobV1);
403    }
404
405    #[test]
406    fn unknown_kind_fails_to_decode() {
407        let error = serde_json::from_str::<ContentRefKind>("\"sparse_file_v9\"")
408            .expect_err("unknown content kind must be rejected");
409        assert_eq!(
410            error.to_string(),
411            "unknown variant `sparse_file_v9`, expected `blob_v1` at line 1 column 16"
412        );
413    }
414
415    #[test]
416    fn every_checksum_algorithm_round_trips() {
417        for (algorithm, wire) in [
418            (ChecksumAlgorithm::Sha256, "sha256"),
419            (ChecksumAlgorithm::Crc64nvme, "crc64nvme"),
420            (ChecksumAlgorithm::Crc32c, "crc32c"),
421        ] {
422            let encoded = serde_json::to_string(&algorithm).expect("encode algorithm");
423            assert_eq!(encoded, format!("\"{wire}\""));
424            assert_eq!(
425                algorithm.as_str(),
426                wire,
427                "the hand-written spelling must match the serde tag"
428            );
429            let decoded: ChecksumAlgorithm =
430                serde_json::from_str(&encoded).expect("decode algorithm");
431            assert_eq!(decoded, algorithm);
432        }
433    }
434
435    #[test]
436    fn an_unknown_checksum_algorithm_fails_to_decode() {
437        assert!(serde_json::from_str::<ChecksumAlgorithm>("\"md5\"").is_err());
438
439        let json = r#"{
440            "kind": "blob_v1",
441            "owner_namespace_id": "demo",
442            "content_id": "con_0123456789abcdef0123456789abcdef",
443            "size_bytes": 5,
444            "checksum": {"algorithm": "md5", "value": "00000000000000000000000000000000"}
445        }"#;
446        assert!(serde_json::from_str::<ContentRef>(json).is_err());
447    }
448
449    #[test]
450    fn a_content_ref_requires_an_owner_and_one_checksum() {
451        let content_ref = ContentRef::blob_v1(
452            crate::NamespaceId::parse("demo").expect("namespace id"),
453            content_id(),
454            b"hello",
455        );
456
457        assert_eq!(content_ref.kind, ContentRefKind::BlobV1);
458        assert_eq!(content_ref.size_bytes, 5);
459        assert_eq!(content_ref.checksum.algorithm, ChecksumAlgorithm::Sha256);
460        content_ref.validate().expect("produced refs validate");
461
462        let document = serde_json::to_value(&content_ref).expect("encode content ref");
463        let object = document.as_object().expect("content ref object");
464        assert_eq!(object.len(), 5);
465        assert_eq!(object["owner_namespace_id"], "demo");
466        let mut missing_owner = document.clone();
467        missing_owner
468            .as_object_mut()
469            .expect("reference")
470            .remove("owner_namespace_id");
471        assert!(serde_json::from_value::<ContentRef>(missing_owner).is_err());
472        assert!(object.contains_key("checksum"));
473        assert!(!object.contains_key("storage_checksum"));
474        assert!(!object.contains_key("whole_file_sha256"));
475    }
476
477    #[test]
478    fn validation_rejects_a_malformed_checksum() {
479        let mut content_ref = ContentRef::blob_v1(
480            crate::NamespaceId::parse("demo").expect("namespace id"),
481            content_id(),
482            b"hello",
483        );
484        content_ref.checksum = Checksum {
485            algorithm: ChecksumAlgorithm::Crc64nvme,
486            value: content_ref.checksum.value.clone(),
487        };
488        assert!(matches!(
489            content_ref.validate(),
490            Err(ContentRefValidationError::InvalidChecksum(
491                ChecksumValidationError::InvalidWidth { .. }
492            ))
493        ));
494    }
495
496    #[test]
497    fn checksum_validation_enforces_exact_widths_and_lowercase_hex() {
498        for (algorithm, width) in [
499            (ChecksumAlgorithm::Sha256, 64),
500            (ChecksumAlgorithm::Crc64nvme, 16),
501            (ChecksumAlgorithm::Crc32c, 8),
502        ] {
503            Checksum {
504                algorithm,
505                value: "a".repeat(width),
506            }
507            .validate()
508            .expect("exact lowercase width");
509
510            assert!(matches!(
511                Checksum {
512                    algorithm,
513                    value: "a".repeat(width - 1),
514                }
515                .validate(),
516                Err(ChecksumValidationError::InvalidWidth { .. })
517            ));
518            assert!(matches!(
519                Checksum {
520                    algorithm,
521                    value: "a".repeat(width + 1),
522                }
523                .validate(),
524                Err(ChecksumValidationError::InvalidWidth { .. })
525            ));
526            assert!(matches!(
527                Checksum {
528                    algorithm,
529                    value: "A".repeat(width),
530                }
531                .validate(),
532                Err(ChecksumValidationError::InvalidAlphabet { .. })
533            ));
534        }
535    }
536
537    #[test]
538    fn crc64nvme_matches_its_catalog_check_value() {
539        assert_eq!(Checksum::crc64nvme(b"123456789").value, "ae8b14860a799888");
540        assert_eq!(
541            Checksum::crc64nvme(b"").value,
542            "0000000000000000",
543            "the empty payload is the identity"
544        );
545    }
546
547    #[test]
548    fn crc32c_matches_its_catalog_check_value() {
549        assert_eq!(Checksum::crc32c(b"123456789").value, "e3069283");
550        assert_eq!(
551            Checksum::crc32c(b"").value,
552            "00000000",
553            "the empty payload is the identity"
554        );
555    }
556
557    #[test]
558    fn a_streamed_checksum_agrees_with_the_whole_payload_at_once() {
559        let payload: Vec<u8> = (0..4096u32).map(|byte| byte as u8).collect();
560        for expected in [
561            Checksum::sha256(&payload),
562            Checksum::crc64nvme(&payload),
563            Checksum::crc32c(&payload),
564        ] {
565            let mut streaming = StreamingChecksum::for_algorithm(expected.algorithm);
566            for chunk in payload.chunks(97) {
567                streaming.update(chunk);
568            }
569            assert_eq!(streaming.finish(), expected);
570        }
571    }
572
573    #[test]
574    fn every_algorithm_compares_bytes_against_the_checksum_they_produce() {
575        for algorithm in [
576            ChecksumAlgorithm::Sha256,
577            ChecksumAlgorithm::Crc64nvme,
578            ChecksumAlgorithm::Crc32c,
579        ] {
580            let expected = Checksum::compute(algorithm, b"hello");
581            assert_eq!(expected.algorithm, algorithm);
582            assert!(expected.matches(b"hello"));
583            assert!(!expected.matches(b"other"));
584        }
585    }
586}