1use 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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
20pub enum ContentRefKind {
21 BlobV1,
23 Unsupported(String),
25}
26
27impl ContentRefKind {
28 const BLOB_V1: &'static str = "blob_v1";
29
30 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
74#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
75#[serde(rename_all = "snake_case")]
76pub enum ChecksumAlgorithm {
77 Sha256,
79 Crc64nvme,
81 Crc32c,
83}
84
85impl ChecksumAlgorithm {
86 pub fn as_str(self) -> &'static str {
88 match self {
89 Self::Sha256 => "sha256",
90 Self::Crc64nvme => "crc64nvme",
91 Self::Crc32c => "crc32c",
92 }
93 }
94
95 pub fn value_bytes(self) -> usize {
97 match self {
98 Self::Sha256 => 32,
99 Self::Crc64nvme => 8,
100 Self::Crc32c => 4,
101 }
102 }
103}
104
105impl fmt::Display for ChecksumAlgorithm {
106 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107 f.write_str(self.as_str())
108 }
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
115#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
116#[serde(deny_unknown_fields)]
117pub struct Checksum {
118 pub algorithm: ChecksumAlgorithm,
120 pub value: String,
125}
126
127impl Checksum {
128 pub fn compute(algorithm: ChecksumAlgorithm, bytes: &[u8]) -> Self {
134 let mut digest = StreamingChecksum::for_algorithm(algorithm);
135 digest.update(bytes);
136 digest.finish()
137 }
138
139 pub fn sha256(bytes: &[u8]) -> Self {
141 Self::compute(ChecksumAlgorithm::Sha256, bytes)
142 }
143
144 pub fn crc64nvme(bytes: &[u8]) -> Self {
146 Self::compute(ChecksumAlgorithm::Crc64nvme, bytes)
147 }
148
149 pub fn crc32c(bytes: &[u8]) -> Self {
151 Self::compute(ChecksumAlgorithm::Crc32c, bytes)
152 }
153
154 pub fn matches(&self, bytes: &[u8]) -> bool {
156 Self::compute(self.algorithm, bytes).value == self.value
157 }
158
159 pub fn validate(&self) -> Result<(), ChecksumValidationError> {
161 let expected_len = self.algorithm.value_bytes() * 2;
162 if self.value.len() != expected_len {
163 return Err(ChecksumValidationError::InvalidWidth {
164 algorithm: self.algorithm,
165 expected_len,
166 actual_len: self.value.len(),
167 });
168 }
169 if !self
170 .value
171 .bytes()
172 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
173 {
174 return Err(ChecksumValidationError::InvalidAlphabet {
175 algorithm: self.algorithm,
176 });
177 }
178 Ok(())
179 }
180}
181
182#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Error)]
184pub enum ChecksumValidationError {
185 #[error(
187 "checksum for algorithm `{algorithm}` must be {expected_len} hex characters, got {actual_len}"
188 )]
189 InvalidWidth {
190 algorithm: ChecksumAlgorithm,
192 expected_len: usize,
194 actual_len: usize,
196 },
197 #[error("checksum for algorithm `{algorithm}` must be lowercase hex")]
199 InvalidAlphabet {
200 algorithm: ChecksumAlgorithm,
202 },
203}
204
205#[derive(Debug)]
210pub enum StreamingChecksum {
211 Sha256(Sha256),
213 Crc64nvme(Crc64Nvme),
215 Crc32c(Crc32c),
217}
218
219impl StreamingChecksum {
220 pub fn for_algorithm(algorithm: ChecksumAlgorithm) -> Self {
222 match algorithm {
223 ChecksumAlgorithm::Sha256 => Self::Sha256(Sha256::new()),
224 ChecksumAlgorithm::Crc64nvme => Self::Crc64nvme(Crc64Nvme::new()),
225 ChecksumAlgorithm::Crc32c => Self::Crc32c(Crc32c::new()),
226 }
227 }
228
229 pub fn update(&mut self, bytes: &[u8]) {
231 match self {
232 Self::Sha256(digest) => digest.update(bytes),
233 Self::Crc64nvme(digest) => digest.update(bytes),
234 Self::Crc32c(digest) => digest.update(bytes),
235 }
236 }
237
238 pub fn finish(self) -> Checksum {
240 match self {
241 Self::Sha256(digest) => digest.finish(),
242 Self::Crc64nvme(digest) => digest.finish(),
243 Self::Crc32c(digest) => digest.finish(),
244 }
245 }
246}
247
248#[derive(Default)]
255pub struct Crc64Nvme {
256 digest: crc64fast_nvme::Digest,
257}
258
259impl Crc64Nvme {
260 pub fn new() -> Self {
262 Self {
263 digest: crc64fast_nvme::Digest::new(),
264 }
265 }
266
267 pub fn update(&mut self, bytes: &[u8]) {
269 self.digest.write(bytes);
270 }
271
272 pub fn finish(self) -> Checksum {
278 Checksum {
279 algorithm: ChecksumAlgorithm::Crc64nvme,
280 value: hex_encode_bytes(&self.digest.sum64().to_be_bytes()),
281 }
282 }
283}
284
285impl fmt::Debug for Crc64Nvme {
286 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
287 f.debug_struct("Crc64Nvme").finish_non_exhaustive()
288 }
289}
290
291#[derive(Default)]
297pub struct Crc32c {
298 crc: u32,
299}
300
301impl Crc32c {
302 pub fn new() -> Self {
304 Self { crc: 0 }
305 }
306
307 pub fn update(&mut self, bytes: &[u8]) {
309 self.crc = crc32c::crc32c_append(self.crc, bytes);
310 }
311
312 pub fn finish(self) -> Checksum {
318 Checksum {
319 algorithm: ChecksumAlgorithm::Crc32c,
320 value: hex_encode_bytes(&self.crc.to_be_bytes()),
321 }
322 }
323}
324
325impl fmt::Debug for Crc32c {
326 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
327 f.debug_struct("Crc32c").finish_non_exhaustive()
328 }
329}
330
331#[derive(Default)]
337pub struct Sha256 {
338 digest: Sha2Sha256,
339}
340
341impl Sha256 {
342 pub fn new() -> Self {
344 Self {
345 digest: Sha2Sha256::new(),
346 }
347 }
348
349 pub fn update(&mut self, bytes: &[u8]) {
351 self.digest.update(bytes);
352 }
353
354 pub fn finish(self) -> Checksum {
356 Checksum {
357 algorithm: ChecksumAlgorithm::Sha256,
358 value: hex_encode_bytes(&self.digest.finalize()),
359 }
360 }
361}
362
363impl fmt::Debug for Sha256 {
364 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
365 f.debug_struct("Sha256").finish_non_exhaustive()
366 }
367}
368
369#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Error)]
371pub enum ContentRefValidationError {
372 #[error("unsupported content ref kind `{kind}`")]
374 UnsupportedKind {
375 kind: String,
377 },
378 #[error("invalid content ref checksum: {0}")]
380 InvalidChecksum(ChecksumValidationError),
381}
382
383#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
392#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
393#[serde(deny_unknown_fields)]
394pub struct ContentRef {
395 #[cfg_attr(feature = "openapi", schema(value_type = String))]
397 pub kind: ContentRefKind,
398 pub content_id: ContentId,
400 pub size_bytes: u64,
402 pub checksum: Checksum,
404}
405
406#[derive(Debug, Clone, Copy)]
408pub enum ContentEvidence<'a> {
409 Bytes(&'a [u8]),
411 ContentRef(&'a ContentRef),
413}
414
415impl ContentRef {
416 pub fn blob_v1(content_id: ContentId, bytes: &[u8]) -> Self {
421 Self {
422 kind: ContentRefKind::BlobV1,
423 content_id,
424 size_bytes: bytes.len() as u64,
425 checksum: Checksum::sha256(bytes),
426 }
427 }
428
429 pub fn blob_v1_streamed(content_id: ContentId, size_bytes: u64, digest: Sha256) -> Self {
435 Self {
436 kind: ContentRefKind::BlobV1,
437 content_id,
438 size_bytes,
439 checksum: digest.finish(),
440 }
441 }
442
443 pub fn matches_evidence(&self, evidence: ContentEvidence<'_>) -> bool {
449 match evidence {
450 ContentEvidence::Bytes(bytes) => {
451 self.size_bytes == bytes.len() as u64 && self.checksum.matches(bytes)
452 }
453 ContentEvidence::ContentRef(reference) => {
454 self.size_bytes == reference.size_bytes && self.checksum == reference.checksum
455 }
456 }
457 }
458
459 pub fn validate(&self) -> Result<(), ContentRefValidationError> {
464 if self.kind != ContentRefKind::BlobV1 {
465 return Err(ContentRefValidationError::UnsupportedKind {
466 kind: self.kind.as_str().to_owned(),
467 });
468 }
469 self.checksum
470 .validate()
471 .map_err(ContentRefValidationError::InvalidChecksum)?;
472 Ok(())
473 }
474}
475
476#[cfg(test)]
477mod tests {
478 use super::{
479 Checksum, ChecksumAlgorithm, ChecksumValidationError, ContentEvidence, ContentRef,
480 ContentRefKind, ContentRefValidationError, Crc32c, Crc64Nvme, StreamingChecksum,
481 };
482 use crate::ids::ContentId;
483
484 fn content_id() -> ContentId {
485 ContentId::parse("con_0123456789abcdef0123456789abcdef").expect("valid content id")
486 }
487
488 #[test]
489 fn known_kind_round_trips_as_snake_case_string() {
490 let encoded = serde_json::to_string(&ContentRefKind::BlobV1).expect("encode");
491 assert_eq!(encoded, "\"blob_v1\"");
492 let decoded: ContentRefKind = serde_json::from_str(&encoded).expect("decode");
493 assert_eq!(decoded, ContentRefKind::BlobV1);
494 }
495
496 #[test]
497 fn unknown_kind_is_preserved_verbatim_through_a_round_trip() {
498 let decoded: ContentRefKind =
499 serde_json::from_str("\"sparse_file_v9\"").expect("decode unknown kind");
500 assert_eq!(
501 decoded,
502 ContentRefKind::Unsupported("sparse_file_v9".to_owned())
503 );
504 let reencoded = serde_json::to_string(&decoded).expect("encode unknown kind");
505 assert_eq!(reencoded, "\"sparse_file_v9\"");
506 }
507
508 #[test]
509 fn every_checksum_algorithm_round_trips() {
510 for (algorithm, wire) in [
511 (ChecksumAlgorithm::Sha256, "\"sha256\""),
512 (ChecksumAlgorithm::Crc64nvme, "\"crc64nvme\""),
513 (ChecksumAlgorithm::Crc32c, "\"crc32c\""),
514 ] {
515 let encoded = serde_json::to_string(&algorithm).expect("encode algorithm");
516 assert_eq!(encoded, wire);
517 let decoded: ChecksumAlgorithm =
518 serde_json::from_str(&encoded).expect("decode algorithm");
519 assert_eq!(decoded, algorithm);
520 }
521 }
522
523 #[test]
524 fn every_checksum_shape_round_trips() {
525 for checksum in [
526 Checksum::sha256(b"hello"),
527 Checksum::crc64nvme(b"hello"),
528 Checksum::crc32c(b"hello"),
529 ] {
530 let encoded = serde_json::to_string(&checksum).expect("encode checksum");
531 let decoded: Checksum = serde_json::from_str(&encoded).expect("decode checksum");
532 assert_eq!(decoded, checksum);
533 }
534 }
535
536 #[test]
543 fn an_unknown_checksum_algorithm_fails_to_decode() {
544 assert!(serde_json::from_str::<ChecksumAlgorithm>("\"md5\"").is_err());
545
546 let json = r#"{
547 "kind": "blob_v1",
548 "content_id": "con_0123456789abcdef0123456789abcdef",
549 "size_bytes": 5,
550 "checksum": {"algorithm": "md5", "value": "00000000000000000000000000000000"}
551 }"#;
552 assert!(serde_json::from_str::<ContentRef>(json).is_err());
553 }
554
555 #[test]
556 fn a_content_ref_rejects_unknown_fields() {
557 let json = r#"{
558 "kind": "blob_v1",
559 "content_id": "con_0123456789abcdef0123456789abcdef",
560 "size_bytes": 5,
561 "checksum": {"algorithm": "sha256", "value": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"},
562 "checksum_type": "full_object"
563 }"#;
564 assert!(serde_json::from_str::<ContentRef>(json).is_err());
565 }
566
567 #[test]
568 fn a_content_ref_uses_only_the_checksum_shape() {
569 let content_ref = ContentRef::blob_v1(content_id(), b"hello");
570
571 assert_eq!(content_ref.kind, ContentRefKind::BlobV1);
572 assert_eq!(content_ref.size_bytes, 5);
573 assert_eq!(content_ref.checksum.algorithm, ChecksumAlgorithm::Sha256);
574 content_ref.validate().expect("produced refs validate");
575
576 let document = serde_json::to_value(&content_ref).expect("encode content ref");
577 let object = document.as_object().expect("content ref object");
578 assert_eq!(object.len(), 4);
579 assert!(object.contains_key("checksum"));
580 assert!(!object.contains_key("storage_checksum"));
581 assert!(!object.contains_key("whole_file_sha256"));
582 }
583
584 #[test]
585 fn validation_rejects_unsupported_kinds_and_malformed_checksums() {
586 let mut content_ref = ContentRef::blob_v1(content_id(), b"hello");
587 content_ref.kind = ContentRefKind::Unsupported("sparse_file_v9".to_owned());
588 assert!(matches!(
589 content_ref.validate(),
590 Err(ContentRefValidationError::UnsupportedKind { .. })
591 ));
592
593 let mut content_ref = ContentRef::blob_v1(content_id(), b"hello");
594 content_ref.checksum = Checksum {
595 algorithm: ChecksumAlgorithm::Crc64nvme,
596 value: content_ref.checksum.value.clone(),
597 };
598 assert!(matches!(
599 content_ref.validate(),
600 Err(ContentRefValidationError::InvalidChecksum(_))
601 ));
602
603 let mut content_ref = ContentRef::blob_v1(content_id(), b"hello");
604 content_ref.checksum.value = content_ref.checksum.value.to_uppercase();
605 assert!(matches!(
606 content_ref.validate(),
607 Err(ContentRefValidationError::InvalidChecksum(
608 ChecksumValidationError::InvalidAlphabet { .. }
609 ))
610 ));
611 }
612
613 #[test]
614 fn checksum_validation_enforces_exact_widths_and_lowercase_hex() {
615 for (algorithm, width) in [
616 (ChecksumAlgorithm::Sha256, 64),
617 (ChecksumAlgorithm::Crc64nvme, 16),
618 (ChecksumAlgorithm::Crc32c, 8),
619 ] {
620 Checksum {
621 algorithm,
622 value: "a".repeat(width),
623 }
624 .validate()
625 .expect("exact lowercase width");
626
627 assert!(matches!(
628 Checksum {
629 algorithm,
630 value: "a".repeat(width - 1),
631 }
632 .validate(),
633 Err(ChecksumValidationError::InvalidWidth { .. })
634 ));
635 assert!(matches!(
636 Checksum {
637 algorithm,
638 value: "a".repeat(width + 1),
639 }
640 .validate(),
641 Err(ChecksumValidationError::InvalidWidth { .. })
642 ));
643 assert!(matches!(
644 Checksum {
645 algorithm,
646 value: "A".repeat(width),
647 }
648 .validate(),
649 Err(ChecksumValidationError::InvalidAlphabet { .. })
650 ));
651 }
652 }
653
654 #[test]
659 fn crc64nvme_matches_its_catalog_check_value() {
660 assert_eq!(Checksum::crc64nvme(b"123456789").value, "ae8b14860a799888");
661 assert_eq!(
662 Checksum::crc64nvme(b"").value,
663 "0000000000000000",
664 "the empty payload is the identity"
665 );
666 }
667
668 #[test]
675 fn crc32c_matches_its_catalog_check_value() {
676 assert_eq!(Checksum::crc32c(b"123456789").value, "e3069283");
677 assert_eq!(
678 Checksum::crc32c(b"").value,
679 "00000000",
680 "the empty payload is the identity"
681 );
682 }
683
684 #[test]
688 fn a_streamed_crc64nvme_equals_the_whole_payload_at_once() {
689 let payload: Vec<u8> = (0..4096u32).map(|byte| byte as u8).collect();
690 let mut streamed = Crc64Nvme::new();
691 for chunk in payload.chunks(97) {
692 streamed.update(chunk);
693 }
694
695 assert_eq!(streamed.finish(), Checksum::crc64nvme(&payload));
696 }
697
698 #[test]
702 fn a_crc32c_folded_over_a_prefix_and_the_rest_equals_the_whole_payload() {
703 let payload: Vec<u8> = (0..4096u32).map(|byte| byte as u8).collect();
704 let mut streamed = Crc32c::new();
705 streamed.update(&payload[..1500]);
706 streamed.update(&payload[1500..]);
707
708 assert_eq!(streamed.finish(), Checksum::crc32c(&payload));
709 }
710
711 #[test]
716 fn a_streamed_checksum_agrees_with_the_whole_payload_at_once() {
717 let payload: Vec<u8> = (0..4096u32).map(|byte| byte as u8).collect();
718 for expected in [
719 Checksum::sha256(&payload),
720 Checksum::crc64nvme(&payload),
721 Checksum::crc32c(&payload),
722 ] {
723 let mut streaming = StreamingChecksum::for_algorithm(expected.algorithm);
724 for chunk in payload.chunks(97) {
725 streaming.update(chunk);
726 }
727 assert_eq!(streaming.finish(), expected);
728 }
729 }
730
731 #[test]
734 fn every_algorithm_compares_bytes_against_the_checksum_they_produce() {
735 for algorithm in [
736 ChecksumAlgorithm::Sha256,
737 ChecksumAlgorithm::Crc64nvme,
738 ChecksumAlgorithm::Crc32c,
739 ] {
740 let expected = Checksum::compute(algorithm, b"hello");
741 assert_eq!(expected.algorithm, algorithm);
742 assert!(expected.matches(b"hello"));
743 assert!(!expected.matches(b"other"));
744 }
745 }
746
747 #[test]
748 fn a_reference_compares_bytes_using_its_checksum_and_size() {
749 let bytes = b"retried payload";
750 let reference = ContentRef {
751 kind: ContentRefKind::BlobV1,
752 content_id: content_id(),
753 size_bytes: bytes.len() as u64,
754 checksum: Checksum::crc32c(bytes),
755 };
756
757 assert!(reference.matches_evidence(ContentEvidence::Bytes(bytes)));
758 assert!(!reference.matches_evidence(ContentEvidence::Bytes(b"different payload")));
759 let mut wrong_size = reference.clone();
760 wrong_size.size_bytes += 1;
761 assert!(!wrong_size.matches_evidence(ContentEvidence::Bytes(bytes)));
762 }
763
764 #[test]
765 fn a_reference_requires_the_other_reference_to_carry_its_checksum_algorithm() {
766 let bytes = b"retried payload";
767 let crc_reference = ContentRef {
768 kind: ContentRefKind::BlobV1,
769 content_id: content_id(),
770 size_bytes: bytes.len() as u64,
771 checksum: Checksum::crc32c(bytes),
772 };
773 let sha_reference = ContentRef::blob_v1(content_id(), bytes);
774 let matching_crc_reference = ContentRef {
775 content_id: content_id(),
776 ..crc_reference.clone()
777 };
778 let different_size = ContentRef {
779 size_bytes: crc_reference.size_bytes + 1,
780 ..crc_reference.clone()
781 };
782
783 assert!(!crc_reference.matches_evidence(ContentEvidence::ContentRef(&sha_reference)));
784 assert!(
785 crc_reference.matches_evidence(ContentEvidence::ContentRef(&matching_crc_reference))
786 );
787 assert!(!crc_reference.matches_evidence(ContentEvidence::ContentRef(&different_size)));
788 assert!(sha_reference.matches_evidence(ContentEvidence::ContentRef(&sha_reference)));
789 }
790
791 #[test]
792 fn sha_and_crc_references_round_trip() {
793 for content_ref in [
794 ContentRef::blob_v1(content_id(), b"hello"),
795 ContentRef {
796 kind: ContentRefKind::BlobV1,
797 content_id: content_id(),
798 size_bytes: 11_534_336,
799 checksum: Checksum {
800 algorithm: ChecksumAlgorithm::Crc64nvme,
801 value: "bbb7305bdf118bcb".to_owned(),
802 },
803 },
804 ContentRef {
805 kind: ContentRefKind::BlobV1,
806 content_id: content_id(),
807 size_bytes: 5,
808 checksum: Checksum::crc32c(b"hello"),
809 },
810 ] {
811 content_ref.validate().expect("content ref is valid");
812 let encoded = serde_json::to_string(&content_ref).expect("encode");
813 let decoded: ContentRef = serde_json::from_str(&encoded).expect("decode");
814 assert_eq!(decoded, content_ref);
815 }
816 }
817}