1use crate::hex::{hex_encode_bytes, is_lower_hex_byte};
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)]
118#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
119#[serde(deny_unknown_fields)]
120pub struct Checksum {
121 pub algorithm: ChecksumAlgorithm,
123 pub value: String,
128}
129
130impl Checksum {
131 pub fn compute(algorithm: ChecksumAlgorithm, bytes: &[u8]) -> Self {
137 let mut digest = StreamingChecksum::for_algorithm(algorithm);
138 digest.update(bytes);
139 digest.finish()
140 }
141
142 pub fn sha256(bytes: &[u8]) -> Self {
144 Self::compute(ChecksumAlgorithm::Sha256, bytes)
145 }
146
147 pub fn crc64nvme(bytes: &[u8]) -> Self {
149 Self::compute(ChecksumAlgorithm::Crc64nvme, bytes)
150 }
151
152 pub fn crc32c(bytes: &[u8]) -> Self {
154 Self::compute(ChecksumAlgorithm::Crc32c, bytes)
155 }
156
157 pub fn matches(&self, bytes: &[u8]) -> bool {
159 Self::compute(self.algorithm, bytes).value == self.value
160 }
161
162 pub fn validate(&self) -> Result<(), ChecksumValidationError> {
164 let expected_len = self.algorithm.value_bytes() * 2;
165 if self.value.len() != expected_len {
166 return Err(ChecksumValidationError::InvalidWidth {
167 algorithm: self.algorithm,
168 expected_len,
169 actual_len: self.value.len(),
170 });
171 }
172 if !self.value.bytes().all(is_lower_hex_byte) {
173 return Err(ChecksumValidationError::InvalidAlphabet {
174 algorithm: self.algorithm,
175 });
176 }
177 Ok(())
178 }
179}
180
181#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Error)]
183pub enum ChecksumValidationError {
184 #[error(
186 "checksum for algorithm `{algorithm}` must be {expected_len} hex characters, got {actual_len}"
187 )]
188 InvalidWidth {
189 algorithm: ChecksumAlgorithm,
191 expected_len: usize,
193 actual_len: usize,
195 },
196 #[error("checksum for algorithm `{algorithm}` must be lowercase hex")]
198 InvalidAlphabet {
199 algorithm: ChecksumAlgorithm,
201 },
202}
203
204#[derive(Debug)]
209pub enum StreamingChecksum {
210 Sha256(Sha256),
212 Crc64nvme(Crc64Nvme),
214 Crc32c(Crc32c),
216}
217
218impl StreamingChecksum {
219 pub fn for_algorithm(algorithm: ChecksumAlgorithm) -> Self {
221 match algorithm {
222 ChecksumAlgorithm::Sha256 => Self::Sha256(Sha256::new()),
223 ChecksumAlgorithm::Crc64nvme => Self::Crc64nvme(Crc64Nvme::new()),
224 ChecksumAlgorithm::Crc32c => Self::Crc32c(Crc32c::new()),
225 }
226 }
227
228 pub fn update(&mut self, bytes: &[u8]) {
230 match self {
231 Self::Sha256(digest) => digest.update(bytes),
232 Self::Crc64nvme(digest) => digest.update(bytes),
233 Self::Crc32c(digest) => digest.update(bytes),
234 }
235 }
236
237 pub fn finish(self) -> Checksum {
239 match self {
240 Self::Sha256(digest) => digest.finish(),
241 Self::Crc64nvme(digest) => digest.finish(),
242 Self::Crc32c(digest) => digest.finish(),
243 }
244 }
245}
246
247#[derive(Default)]
254pub struct Crc64Nvme {
255 digest: crc64fast_nvme::Digest,
256}
257
258impl Crc64Nvme {
259 pub fn new() -> Self {
261 Self {
262 digest: crc64fast_nvme::Digest::new(),
263 }
264 }
265
266 pub fn update(&mut self, bytes: &[u8]) {
268 self.digest.write(bytes);
269 }
270
271 pub fn finish(self) -> Checksum {
277 Checksum {
278 algorithm: ChecksumAlgorithm::Crc64nvme,
279 value: hex_encode_bytes(&self.digest.sum64().to_be_bytes()),
280 }
281 }
282}
283
284impl fmt::Debug for Crc64Nvme {
285 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
286 f.debug_struct("Crc64Nvme").finish_non_exhaustive()
287 }
288}
289
290#[derive(Default)]
296pub struct Crc32c {
297 crc: u32,
298}
299
300impl Crc32c {
301 pub fn new() -> Self {
303 Self { crc: 0 }
304 }
305
306 pub fn update(&mut self, bytes: &[u8]) {
308 self.crc = crc32c::crc32c_append(self.crc, bytes);
309 }
310
311 pub fn finish(self) -> Checksum {
317 Checksum {
318 algorithm: ChecksumAlgorithm::Crc32c,
319 value: hex_encode_bytes(&self.crc.to_be_bytes()),
320 }
321 }
322}
323
324impl fmt::Debug for Crc32c {
325 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
326 f.debug_struct("Crc32c").finish_non_exhaustive()
327 }
328}
329
330#[derive(Default)]
336pub struct Sha256 {
337 digest: Sha2Sha256,
338}
339
340impl Sha256 {
341 pub fn new() -> Self {
343 Self {
344 digest: Sha2Sha256::new(),
345 }
346 }
347
348 pub fn update(&mut self, bytes: &[u8]) {
350 self.digest.update(bytes);
351 }
352
353 pub fn finish(self) -> Checksum {
355 Checksum {
356 algorithm: ChecksumAlgorithm::Sha256,
357 value: hex_encode_bytes(&self.digest.finalize()),
358 }
359 }
360}
361
362impl fmt::Debug for Sha256 {
363 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
364 f.debug_struct("Sha256").finish_non_exhaustive()
365 }
366}
367
368#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Error)]
370pub enum ContentRefValidationError {
371 #[error("unsupported content ref kind `{kind}`")]
373 UnsupportedKind {
374 kind: String,
376 },
377 #[error("invalid content ref checksum: {0}")]
379 InvalidChecksum(ChecksumValidationError),
380}
381
382#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
394#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
395#[serde(deny_unknown_fields)]
396pub struct ContentRef {
397 #[cfg_attr(feature = "openapi", schema(value_type = String))]
399 pub kind: ContentRefKind,
400 pub content_id: ContentId,
402 pub size_bytes: u64,
404 pub checksum: Checksum,
406}
407
408#[derive(Debug, Clone, Copy)]
410pub enum ContentEvidence<'a> {
411 Bytes(&'a [u8]),
413 ContentRef(&'a ContentRef),
415}
416
417impl ContentRef {
418 pub fn blob_v1(content_id: ContentId, bytes: &[u8]) -> Self {
423 Self {
424 kind: ContentRefKind::BlobV1,
425 content_id,
426 size_bytes: bytes.len() as u64,
427 checksum: Checksum::sha256(bytes),
428 }
429 }
430
431 pub fn blob_v1_streamed(content_id: ContentId, size_bytes: u64, digest: Sha256) -> Self {
437 Self {
438 kind: ContentRefKind::BlobV1,
439 content_id,
440 size_bytes,
441 checksum: digest.finish(),
442 }
443 }
444
445 pub fn matches_evidence(&self, evidence: ContentEvidence<'_>) -> bool {
451 match evidence {
452 ContentEvidence::Bytes(bytes) => {
453 self.size_bytes == bytes.len() as u64 && self.checksum.matches(bytes)
454 }
455 ContentEvidence::ContentRef(reference) => {
456 self.size_bytes == reference.size_bytes && self.checksum == reference.checksum
457 }
458 }
459 }
460
461 pub fn validate(&self) -> Result<(), ContentRefValidationError> {
466 if self.kind != ContentRefKind::BlobV1 {
467 return Err(ContentRefValidationError::UnsupportedKind {
468 kind: self.kind.as_str().to_owned(),
469 });
470 }
471 self.checksum
472 .validate()
473 .map_err(ContentRefValidationError::InvalidChecksum)?;
474 Ok(())
475 }
476}
477
478#[cfg(test)]
479mod tests {
480 use super::{
481 Checksum, ChecksumAlgorithm, ChecksumValidationError, ContentEvidence, ContentRef,
482 ContentRefKind, ContentRefValidationError, StreamingChecksum,
483 };
484 use crate::ids::ContentId;
485
486 fn content_id() -> ContentId {
487 ContentId::parse("con_0123456789abcdef0123456789abcdef").expect("valid content id")
488 }
489
490 #[test]
491 fn known_kind_round_trips_as_snake_case_string() {
492 let encoded = serde_json::to_string(&ContentRefKind::BlobV1).expect("encode");
493 assert_eq!(encoded, "\"blob_v1\"");
494 let decoded: ContentRefKind = serde_json::from_str(&encoded).expect("decode");
495 assert_eq!(decoded, ContentRefKind::BlobV1);
496 }
497
498 #[test]
499 fn unknown_kind_is_preserved_verbatim_through_a_round_trip() {
500 let decoded: ContentRefKind =
501 serde_json::from_str("\"sparse_file_v9\"").expect("decode unknown kind");
502 assert_eq!(
503 decoded,
504 ContentRefKind::Unsupported("sparse_file_v9".to_owned())
505 );
506 let reencoded = serde_json::to_string(&decoded).expect("encode unknown kind");
507 assert_eq!(reencoded, "\"sparse_file_v9\"");
508 }
509
510 #[test]
511 fn every_checksum_algorithm_round_trips() {
512 for (algorithm, wire) in [
513 (ChecksumAlgorithm::Sha256, "sha256"),
514 (ChecksumAlgorithm::Crc64nvme, "crc64nvme"),
515 (ChecksumAlgorithm::Crc32c, "crc32c"),
516 ] {
517 let encoded = serde_json::to_string(&algorithm).expect("encode algorithm");
518 assert_eq!(encoded, format!("\"{wire}\""));
519 assert_eq!(
520 algorithm.as_str(),
521 wire,
522 "the hand-written spelling must match the serde tag"
523 );
524 let decoded: ChecksumAlgorithm =
525 serde_json::from_str(&encoded).expect("decode algorithm");
526 assert_eq!(decoded, algorithm);
527 }
528 }
529
530 #[test]
531 fn an_unknown_checksum_algorithm_fails_to_decode() {
532 assert!(serde_json::from_str::<ChecksumAlgorithm>("\"md5\"").is_err());
533
534 let json = r#"{
535 "kind": "blob_v1",
536 "content_id": "con_0123456789abcdef0123456789abcdef",
537 "size_bytes": 5,
538 "checksum": {"algorithm": "md5", "value": "00000000000000000000000000000000"}
539 }"#;
540 assert!(serde_json::from_str::<ContentRef>(json).is_err());
541 }
542
543 #[test]
544 fn a_content_ref_uses_only_the_checksum_shape() {
545 let content_ref = ContentRef::blob_v1(content_id(), b"hello");
546
547 assert_eq!(content_ref.kind, ContentRefKind::BlobV1);
548 assert_eq!(content_ref.size_bytes, 5);
549 assert_eq!(content_ref.checksum.algorithm, ChecksumAlgorithm::Sha256);
550 content_ref.validate().expect("produced refs validate");
551
552 let document = serde_json::to_value(&content_ref).expect("encode content ref");
553 let object = document.as_object().expect("content ref object");
554 assert_eq!(object.len(), 4);
555 assert!(object.contains_key("checksum"));
556 assert!(!object.contains_key("storage_checksum"));
557 assert!(!object.contains_key("whole_file_sha256"));
558 }
559
560 #[test]
561 fn validation_rejects_an_unsupported_kind_and_a_malformed_checksum() {
562 let mut content_ref = ContentRef::blob_v1(content_id(), b"hello");
563 content_ref.kind = ContentRefKind::Unsupported("sparse_file_v9".to_owned());
564 assert!(matches!(
565 content_ref.validate(),
566 Err(ContentRefValidationError::UnsupportedKind { .. })
567 ));
568
569 let mut content_ref = ContentRef::blob_v1(content_id(), b"hello");
570 content_ref.checksum = Checksum {
571 algorithm: ChecksumAlgorithm::Crc64nvme,
572 value: content_ref.checksum.value.clone(),
573 };
574 assert!(matches!(
575 content_ref.validate(),
576 Err(ContentRefValidationError::InvalidChecksum(
577 ChecksumValidationError::InvalidWidth { .. }
578 ))
579 ));
580 }
581
582 #[test]
583 fn checksum_validation_enforces_exact_widths_and_lowercase_hex() {
584 for (algorithm, width) in [
585 (ChecksumAlgorithm::Sha256, 64),
586 (ChecksumAlgorithm::Crc64nvme, 16),
587 (ChecksumAlgorithm::Crc32c, 8),
588 ] {
589 Checksum {
590 algorithm,
591 value: "a".repeat(width),
592 }
593 .validate()
594 .expect("exact lowercase width");
595
596 assert!(matches!(
597 Checksum {
598 algorithm,
599 value: "a".repeat(width - 1),
600 }
601 .validate(),
602 Err(ChecksumValidationError::InvalidWidth { .. })
603 ));
604 assert!(matches!(
605 Checksum {
606 algorithm,
607 value: "a".repeat(width + 1),
608 }
609 .validate(),
610 Err(ChecksumValidationError::InvalidWidth { .. })
611 ));
612 assert!(matches!(
613 Checksum {
614 algorithm,
615 value: "A".repeat(width),
616 }
617 .validate(),
618 Err(ChecksumValidationError::InvalidAlphabet { .. })
619 ));
620 }
621 }
622
623 #[test]
624 fn crc64nvme_matches_its_catalog_check_value() {
625 assert_eq!(Checksum::crc64nvme(b"123456789").value, "ae8b14860a799888");
626 assert_eq!(
627 Checksum::crc64nvme(b"").value,
628 "0000000000000000",
629 "the empty payload is the identity"
630 );
631 }
632
633 #[test]
634 fn crc32c_matches_its_catalog_check_value() {
635 assert_eq!(Checksum::crc32c(b"123456789").value, "e3069283");
636 assert_eq!(
637 Checksum::crc32c(b"").value,
638 "00000000",
639 "the empty payload is the identity"
640 );
641 }
642
643 #[test]
644 fn a_streamed_checksum_agrees_with_the_whole_payload_at_once() {
645 let payload: Vec<u8> = (0..4096u32).map(|byte| byte as u8).collect();
646 for expected in [
647 Checksum::sha256(&payload),
648 Checksum::crc64nvme(&payload),
649 Checksum::crc32c(&payload),
650 ] {
651 let mut streaming = StreamingChecksum::for_algorithm(expected.algorithm);
652 for chunk in payload.chunks(97) {
653 streaming.update(chunk);
654 }
655 assert_eq!(streaming.finish(), expected);
656 }
657 }
658
659 #[test]
660 fn every_algorithm_compares_bytes_against_the_checksum_they_produce() {
661 for algorithm in [
662 ChecksumAlgorithm::Sha256,
663 ChecksumAlgorithm::Crc64nvme,
664 ChecksumAlgorithm::Crc32c,
665 ] {
666 let expected = Checksum::compute(algorithm, b"hello");
667 assert_eq!(expected.algorithm, algorithm);
668 assert!(expected.matches(b"hello"));
669 assert!(!expected.matches(b"other"));
670 }
671 }
672
673 #[test]
674 fn a_reference_compares_bytes_using_its_checksum_and_size() {
675 let bytes = b"retried payload";
676 let reference = ContentRef {
677 kind: ContentRefKind::BlobV1,
678 content_id: content_id(),
679 size_bytes: bytes.len() as u64,
680 checksum: Checksum::crc32c(bytes),
681 };
682
683 assert!(reference.matches_evidence(ContentEvidence::Bytes(bytes)));
684 assert!(!reference.matches_evidence(ContentEvidence::Bytes(b"different payload")));
685 let mut wrong_size = reference.clone();
686 wrong_size.size_bytes += 1;
687 assert!(!wrong_size.matches_evidence(ContentEvidence::Bytes(bytes)));
688 }
689
690 #[test]
691 fn a_reference_requires_the_other_reference_to_carry_its_checksum_algorithm() {
692 let bytes = b"retried payload";
693 let crc_reference = ContentRef {
694 kind: ContentRefKind::BlobV1,
695 content_id: content_id(),
696 size_bytes: bytes.len() as u64,
697 checksum: Checksum::crc32c(bytes),
698 };
699 let sha_reference = ContentRef::blob_v1(content_id(), bytes);
700 let matching_crc_reference = ContentRef {
701 content_id: content_id(),
702 ..crc_reference.clone()
703 };
704 let different_size = ContentRef {
705 size_bytes: crc_reference.size_bytes + 1,
706 ..crc_reference.clone()
707 };
708
709 assert!(!crc_reference.matches_evidence(ContentEvidence::ContentRef(&sha_reference)));
710 assert!(
711 crc_reference.matches_evidence(ContentEvidence::ContentRef(&matching_crc_reference))
712 );
713 assert!(!crc_reference.matches_evidence(ContentEvidence::ContentRef(&different_size)));
714 assert!(sha_reference.matches_evidence(ContentEvidence::ContentRef(&sha_reference)));
715 }
716}