use crate::hex::hex_encode_bytes;
use crate::ids::ContentId;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256 as Sha2Sha256};
use std::fmt;
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ContentRefKind {
BlobV1,
Unsupported(String),
}
impl ContentRefKind {
const BLOB_V1: &'static str = "blob_v1";
pub fn as_str(&self) -> &str {
match self {
Self::BlobV1 => Self::BLOB_V1,
Self::Unsupported(other) => other,
}
}
}
impl fmt::Display for ContentRefKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl Serialize for ContentRefKind {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for ContentRefKind {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Ok(match value.as_str() {
Self::BLOB_V1 => Self::BlobV1,
_ => Self::Unsupported(value),
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum ChecksumAlgorithm {
Sha256,
Crc64nvme,
Crc32c,
}
impl ChecksumAlgorithm {
pub fn as_str(self) -> &'static str {
match self {
Self::Sha256 => "sha256",
Self::Crc64nvme => "crc64nvme",
Self::Crc32c => "crc32c",
}
}
pub fn value_bytes(self) -> usize {
match self {
Self::Sha256 => 32,
Self::Crc64nvme => 8,
Self::Crc32c => 4,
}
}
}
impl fmt::Display for ChecksumAlgorithm {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct StorageChecksum {
pub algorithm: ChecksumAlgorithm,
pub value: String,
}
impl StorageChecksum {
pub fn sha256(bytes: &[u8]) -> Self {
Self {
algorithm: ChecksumAlgorithm::Sha256,
value: hex_encode_bytes(&Sha2Sha256::digest(bytes)),
}
}
pub fn crc64nvme(bytes: &[u8]) -> Self {
let mut digest = Crc64Nvme::new();
digest.update(bytes);
digest.finish()
}
pub fn matches(&self, bytes: &[u8]) -> Option<bool> {
let recomputed = match self.algorithm {
ChecksumAlgorithm::Sha256 => Self::sha256(bytes),
ChecksumAlgorithm::Crc64nvme => Self::crc64nvme(bytes),
ChecksumAlgorithm::Crc32c => return None,
};
Some(recomputed.value == self.value)
}
}
#[derive(Debug)]
pub enum StreamingChecksum {
Sha256(Sha256),
Crc64nvme(Crc64Nvme),
}
impl StreamingChecksum {
pub fn for_algorithm(algorithm: ChecksumAlgorithm) -> Option<Self> {
match algorithm {
ChecksumAlgorithm::Sha256 => Some(Self::Sha256(Sha256::new())),
ChecksumAlgorithm::Crc64nvme => Some(Self::Crc64nvme(Crc64Nvme::new())),
ChecksumAlgorithm::Crc32c => None,
}
}
pub fn update(&mut self, bytes: &[u8]) {
match self {
Self::Sha256(digest) => digest.update(bytes),
Self::Crc64nvme(digest) => digest.update(bytes),
}
}
pub fn finish(self) -> StorageChecksum {
match self {
Self::Sha256(digest) => digest.finish(),
Self::Crc64nvme(digest) => digest.finish(),
}
}
}
#[derive(Default)]
pub struct Crc64Nvme {
digest: crc64fast_nvme::Digest,
}
impl Crc64Nvme {
pub fn new() -> Self {
Self {
digest: crc64fast_nvme::Digest::new(),
}
}
pub fn update(&mut self, bytes: &[u8]) {
self.digest.write(bytes);
}
pub fn finish(self) -> StorageChecksum {
StorageChecksum {
algorithm: ChecksumAlgorithm::Crc64nvme,
value: hex_encode_bytes(&self.digest.sum64().to_be_bytes()),
}
}
}
impl fmt::Debug for Crc64Nvme {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Crc64Nvme").finish_non_exhaustive()
}
}
#[derive(Default)]
pub struct Sha256 {
digest: Sha2Sha256,
}
impl Sha256 {
pub fn new() -> Self {
Self {
digest: Sha2Sha256::new(),
}
}
pub fn update(&mut self, bytes: &[u8]) {
self.digest.update(bytes);
}
pub fn finish(self) -> StorageChecksum {
StorageChecksum {
algorithm: ChecksumAlgorithm::Sha256,
value: hex_encode_bytes(&self.digest.finalize()),
}
}
}
impl fmt::Debug for Sha256 {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Sha256").finish_non_exhaustive()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Error)]
pub enum ContentRefValidationError {
#[error("unsupported content ref kind `{kind}`")]
UnsupportedKind {
kind: String,
},
#[error("invalid {field} for algorithm `{algorithm}`: {reason}")]
InvalidChecksum {
field: String,
algorithm: ChecksumAlgorithm,
reason: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct ContentRef {
#[cfg_attr(feature = "openapi", schema(value_type = String))]
pub kind: ContentRefKind,
pub content_id: ContentId,
pub size_bytes: u64,
pub storage_checksum: StorageChecksum,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub whole_file_sha256: Option<String>,
}
impl ContentRef {
pub fn blob_v1(content_id: ContentId, bytes: &[u8]) -> Self {
let storage_checksum = StorageChecksum::sha256(bytes);
Self {
kind: ContentRefKind::BlobV1,
content_id,
size_bytes: bytes.len() as u64,
whole_file_sha256: Some(storage_checksum.value.clone()),
storage_checksum,
}
}
pub fn blob_v1_streamed(content_id: ContentId, size_bytes: u64, digest: Sha256) -> Self {
let storage_checksum = digest.finish();
Self {
kind: ContentRefKind::BlobV1,
content_id,
size_bytes,
whole_file_sha256: Some(storage_checksum.value.clone()),
storage_checksum,
}
}
pub fn validate(&self) -> Result<(), ContentRefValidationError> {
if self.kind != ContentRefKind::BlobV1 {
return Err(ContentRefValidationError::UnsupportedKind {
kind: self.kind.as_str().to_owned(),
});
}
validate_checksum_value(
"storage_checksum",
self.storage_checksum.algorithm,
&self.storage_checksum.value,
)?;
if let Some(whole_file_sha256) = &self.whole_file_sha256 {
validate_checksum_value(
"whole_file_sha256",
ChecksumAlgorithm::Sha256,
whole_file_sha256,
)?;
}
Ok(())
}
}
fn validate_checksum_value(
field: &str,
algorithm: ChecksumAlgorithm,
value: &str,
) -> Result<(), ContentRefValidationError> {
let expected_len = algorithm.value_bytes() * 2;
if value.len() != expected_len {
return Err(ContentRefValidationError::InvalidChecksum {
field: field.to_owned(),
algorithm,
reason: format!("must be {expected_len} hex characters, got {}", value.len()),
});
}
if !value
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
return Err(ContentRefValidationError::InvalidChecksum {
field: field.to_owned(),
algorithm,
reason: "must be lowercase hex".to_owned(),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{
ChecksumAlgorithm, ContentRef, ContentRefKind, ContentRefValidationError, Crc64Nvme,
StorageChecksum, StreamingChecksum,
};
use crate::ids::ContentId;
fn content_id() -> ContentId {
ContentId::parse("con_0123456789abcdef0123456789abcdef").expect("valid content id")
}
#[test]
fn known_kind_round_trips_as_snake_case_string() {
let encoded = serde_json::to_string(&ContentRefKind::BlobV1).expect("encode");
assert_eq!(encoded, "\"blob_v1\"");
let decoded: ContentRefKind = serde_json::from_str(&encoded).expect("decode");
assert_eq!(decoded, ContentRefKind::BlobV1);
}
#[test]
fn unknown_kind_is_preserved_verbatim_through_a_round_trip() {
let decoded: ContentRefKind =
serde_json::from_str("\"sparse_file_v9\"").expect("decode unknown kind");
assert_eq!(
decoded,
ContentRefKind::Unsupported("sparse_file_v9".to_owned())
);
let reencoded = serde_json::to_string(&decoded).expect("encode unknown kind");
assert_eq!(reencoded, "\"sparse_file_v9\"");
}
#[test]
fn every_checksum_algorithm_round_trips() {
for (algorithm, wire) in [
(ChecksumAlgorithm::Sha256, "\"sha256\""),
(ChecksumAlgorithm::Crc64nvme, "\"crc64nvme\""),
(ChecksumAlgorithm::Crc32c, "\"crc32c\""),
] {
let encoded = serde_json::to_string(&algorithm).expect("encode algorithm");
assert_eq!(encoded, wire);
let decoded: ChecksumAlgorithm =
serde_json::from_str(&encoded).expect("decode algorithm");
assert_eq!(decoded, algorithm);
}
}
#[test]
fn a_content_ref_rejects_unknown_fields() {
let json = r#"{
"kind": "blob_v1",
"content_id": "con_0123456789abcdef0123456789abcdef",
"size_bytes": 5,
"storage_checksum": {"algorithm": "sha256", "value": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"},
"checksum_type": "full_object"
}"#;
assert!(serde_json::from_str::<ContentRef>(json).is_err());
}
#[test]
fn a_produced_reference_carries_a_trusted_whole_file_sha256() {
let content_ref = ContentRef::blob_v1(content_id(), b"hello");
assert_eq!(content_ref.kind, ContentRefKind::BlobV1);
assert_eq!(content_ref.size_bytes, 5);
assert_eq!(
content_ref.storage_checksum.algorithm,
ChecksumAlgorithm::Sha256
);
assert_eq!(
content_ref.whole_file_sha256.as_deref(),
Some(content_ref.storage_checksum.value.as_str())
);
content_ref.validate().expect("produced refs validate");
}
#[test]
fn validation_rejects_unsupported_kinds_and_malformed_checksums() {
let mut content_ref = ContentRef::blob_v1(content_id(), b"hello");
content_ref.kind = ContentRefKind::Unsupported("sparse_file_v9".to_owned());
assert!(matches!(
content_ref.validate(),
Err(ContentRefValidationError::UnsupportedKind { .. })
));
let mut content_ref = ContentRef::blob_v1(content_id(), b"hello");
content_ref.storage_checksum = StorageChecksum {
algorithm: ChecksumAlgorithm::Crc64nvme,
value: content_ref.storage_checksum.value.clone(),
};
assert!(matches!(
content_ref.validate(),
Err(ContentRefValidationError::InvalidChecksum { .. })
));
let mut content_ref = ContentRef::blob_v1(content_id(), b"hello");
content_ref.whole_file_sha256 = Some(content_ref.storage_checksum.value.to_uppercase());
assert!(matches!(
content_ref.validate(),
Err(ContentRefValidationError::InvalidChecksum { .. })
));
}
#[test]
fn crc64nvme_matches_its_catalog_check_value() {
assert_eq!(
StorageChecksum::crc64nvme(b"123456789").value,
"ae8b14860a799888"
);
assert_eq!(
StorageChecksum::crc64nvme(b"").value,
"0000000000000000",
"the empty payload is the identity"
);
}
#[test]
fn a_streamed_crc64nvme_equals_the_whole_payload_at_once() {
let payload: Vec<u8> = (0..4096u32).map(|byte| byte as u8).collect();
let mut streamed = Crc64Nvme::new();
for chunk in payload.chunks(97) {
streamed.update(chunk);
}
assert_eq!(streamed.finish(), StorageChecksum::crc64nvme(&payload));
}
#[test]
fn a_streamed_checksum_agrees_with_the_whole_payload_at_once() {
let payload: Vec<u8> = (0..4096u32).map(|byte| byte as u8).collect();
for expected in [
StorageChecksum::sha256(&payload),
StorageChecksum::crc64nvme(&payload),
] {
let mut streaming = StreamingChecksum::for_algorithm(expected.algorithm)
.expect("a producing algorithm folds");
for chunk in payload.chunks(97) {
streaming.update(chunk);
}
assert_eq!(streaming.finish(), expected);
}
assert!(StreamingChecksum::for_algorithm(ChecksumAlgorithm::Crc32c).is_none());
}
#[test]
fn checksum_matching_refuses_rather_than_agrees_when_it_cannot_recompute() {
assert_eq!(
StorageChecksum::sha256(b"hello").matches(b"hello"),
Some(true)
);
assert_eq!(
StorageChecksum::sha256(b"hello").matches(b"other"),
Some(false)
);
assert_eq!(
StorageChecksum::crc64nvme(b"hello").matches(b"hello"),
Some(true)
);
assert_eq!(
StorageChecksum {
algorithm: ChecksumAlgorithm::Crc32c,
value: "00000000".to_owned(),
}
.matches(b"hello"),
None
);
}
#[test]
fn a_crc_only_reference_round_trips_without_a_whole_file_sha256() {
let content_ref = ContentRef {
kind: ContentRefKind::BlobV1,
content_id: content_id(),
size_bytes: 11_534_336,
storage_checksum: StorageChecksum {
algorithm: ChecksumAlgorithm::Crc64nvme,
value: "bbb7305bdf118bcb".to_owned(),
},
whole_file_sha256: None,
};
content_ref.validate().expect("crc-only refs are valid");
let encoded = serde_json::to_string(&content_ref).expect("encode");
assert!(!encoded.contains("whole_file_sha256"));
let decoded: ContentRef = serde_json::from_str(&encoded).expect("decode");
assert_eq!(decoded, content_ref);
}
}