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)]
81#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
82#[serde(rename_all = "snake_case")]
83pub enum ChecksumAlgorithm {
84 Sha256,
86 Crc64nvme,
88 Crc32c,
90}
91
92impl ChecksumAlgorithm {
93 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 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#[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 pub algorithm: ChecksumAlgorithm,
125 pub value: String,
130}
131
132impl StorageChecksum {
133 pub fn sha256(bytes: &[u8]) -> Self {
135 Self {
136 algorithm: ChecksumAlgorithm::Sha256,
137 value: hex_encode_bytes(&Sha2Sha256::digest(bytes)),
138 }
139 }
140
141 pub fn crc64nvme(bytes: &[u8]) -> Self {
143 let mut digest = Crc64Nvme::new();
144 digest.update(bytes);
145 digest.finish()
146 }
147
148 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#[derive(Debug)]
171pub enum StreamingChecksum {
172 Sha256(Sha256),
174 Crc64nvme(Crc64Nvme),
176}
177
178impl StreamingChecksum {
179 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 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 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#[derive(Default)]
213pub struct Crc64Nvme {
214 digest: crc64fast_nvme::Digest,
215}
216
217impl Crc64Nvme {
218 pub fn new() -> Self {
220 Self {
221 digest: crc64fast_nvme::Digest::new(),
222 }
223 }
224
225 pub fn update(&mut self, bytes: &[u8]) {
227 self.digest.write(bytes);
228 }
229
230 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#[derive(Default)]
255pub struct Sha256 {
256 digest: Sha2Sha256,
257}
258
259impl Sha256 {
260 pub fn new() -> Self {
262 Self {
263 digest: Sha2Sha256::new(),
264 }
265 }
266
267 pub fn update(&mut self, bytes: &[u8]) {
269 self.digest.update(bytes);
270 }
271
272 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Error)]
289pub enum ContentRefValidationError {
290 #[error("unsupported content ref kind `{kind}`")]
292 UnsupportedKind {
293 kind: String,
295 },
296 #[error("invalid {field} for algorithm `{algorithm}`: {reason}")]
298 InvalidChecksum {
299 field: String,
301 algorithm: ChecksumAlgorithm,
303 reason: String,
305 },
306}
307
308#[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 #[cfg_attr(feature = "openapi", schema(value_type = String))]
322 pub kind: ContentRefKind,
323 pub content_id: ContentId,
325 pub size_bytes: u64,
327 pub storage_checksum: StorageChecksum,
330 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub whole_file_sha256: Option<String>,
339}
340
341impl ContentRef {
342 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 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 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 #[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 #[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 #[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 #[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}