1use 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#[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 BlobV1,
17}
18
19impl ContentRefKind {
20 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#[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 Sha256,
41 Crc64nvme,
43 Crc32c,
45}
46
47impl ChecksumAlgorithm {
48 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 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#[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 pub algorithm: ChecksumAlgorithm,
83 pub value: String,
85}
86
87impl Checksum {
88 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 pub fn sha256(bytes: &[u8]) -> Self {
101 Self::compute(ChecksumAlgorithm::Sha256, bytes)
102 }
103
104 pub fn crc64nvme(bytes: &[u8]) -> Self {
106 Self::compute(ChecksumAlgorithm::Crc64nvme, bytes)
107 }
108
109 pub fn crc32c(bytes: &[u8]) -> Self {
111 Self::compute(ChecksumAlgorithm::Crc32c, bytes)
112 }
113
114 pub fn matches(&self, bytes: &[u8]) -> bool {
116 Self::compute(self.algorithm, bytes).value == self.value
117 }
118
119 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Error)]
140pub enum ChecksumValidationError {
141 #[error(
143 "checksum for algorithm `{algorithm}` must be {expected_len} hex characters, got {actual_len}"
144 )]
145 InvalidWidth {
146 algorithm: ChecksumAlgorithm,
148 expected_len: usize,
150 actual_len: usize,
152 },
153 #[error("checksum for algorithm `{algorithm}` must be lowercase hex")]
155 InvalidAlphabet {
156 algorithm: ChecksumAlgorithm,
158 },
159}
160
161#[derive(Debug)]
163pub enum StreamingChecksum {
164 Sha256(Sha256),
166 Crc64nvme(Crc64Nvme),
168 Crc32c(Crc32c),
170}
171
172impl StreamingChecksum {
173 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 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 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#[derive(Default)]
203pub struct Crc64Nvme {
204 digest: crc64fast_nvme::Digest,
205}
206
207impl Crc64Nvme {
208 pub fn new() -> Self {
210 Self {
211 digest: crc64fast_nvme::Digest::new(),
212 }
213 }
214
215 pub fn update(&mut self, bytes: &[u8]) {
217 self.digest.write(bytes);
218 }
219
220 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#[derive(Default)]
241pub struct Crc32c {
242 crc: u32,
243}
244
245impl Crc32c {
246 pub fn new() -> Self {
248 Self { crc: 0 }
249 }
250
251 pub fn update(&mut self, bytes: &[u8]) {
253 self.crc = crc32c::crc32c_append(self.crc, bytes);
254 }
255
256 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#[derive(Default)]
277pub struct Sha256 {
278 digest: Sha2Sha256,
279}
280
281impl Sha256 {
282 pub fn new() -> Self {
284 Self {
285 digest: Sha2Sha256::new(),
286 }
287 }
288
289 pub fn update(&mut self, bytes: &[u8]) {
291 self.digest.update(bytes);
292 }
293
294 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Error)]
311pub enum ContentRefValidationError {
312 #[error("invalid content ref checksum: {0}")]
314 InvalidChecksum(ChecksumValidationError),
315}
316
317#[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 pub kind: ContentRefKind,
328 pub owner_namespace_id: NamespaceId,
330 pub content_id: ContentId,
332 pub size_bytes: u64,
334 pub checksum: Checksum,
336}
337
338impl ContentRef {
339 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 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 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}