use serde::{Deserialize, Deserializer, Serialize};
use crate::error::ScrybeError;
const DIGEST_BYTES: usize = 32;
const DIGEST_HEX_LEN: usize = DIGEST_BYTES * 2;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
pub struct ContentDigest(String);
impl ContentDigest {
pub fn of(content: &[u8]) -> Self {
let hash = blake3::hash(content);
Self(hex::encode(hash.as_bytes()))
}
pub fn from_hex(s: &str) -> Result<Self, ScrybeError> {
if s.len() != DIGEST_HEX_LEN {
return Err(ScrybeError::InvalidDigest(format!(
"expected {DIGEST_HEX_LEN} hex characters, got {}",
s.len()
)));
}
if !s.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(ScrybeError::InvalidDigest(
"expected only hexadecimal characters [0-9a-f]".to_string(),
));
}
Ok(Self(s.to_ascii_lowercase()))
}
pub fn as_hex(&self) -> &str {
&self.0
}
pub fn verify(&self, content: &[u8]) -> bool {
Self::of(content) == *self
}
}
impl std::fmt::Display for ContentDigest {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::str::FromStr for ContentDigest {
type Err = ScrybeError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::from_hex(s)
}
}
impl<'de> Deserialize<'de> for ContentDigest {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Self::from_hex(&s).map_err(serde::de::Error::custom)
}
}
#[deprecated(note = "renamed to `ContentDigest`: this is a BLAKE3 hex digest, not a CID")]
pub type ContentId = ContentDigest;
pub trait ContentAddressable {
fn content_digest(&self) -> ContentDigest;
#[deprecated(note = "renamed to `content_digest`: this is a BLAKE3 hex digest, not a CID")]
fn content_id(&self) -> ContentDigest {
self.content_digest()
}
}
#[cfg(test)]
mod tests {
use super::*;
const EMPTY_B3: &str = "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262";
#[test]
fn test_digest_deterministic() {
let a = ContentDigest::of(b"hello scrybe");
let b = ContentDigest::of(b"hello scrybe");
assert_eq!(a, b);
}
#[test]
fn test_digest_differs_for_different_content() {
let a = ContentDigest::of(b"foo");
let b = ContentDigest::of(b"bar");
assert_ne!(a, b);
}
#[test]
fn test_verify_roundtrip() {
let content = b"verifiable content";
let digest = ContentDigest::of(content);
assert!(digest.verify(content));
assert!(!digest.verify(b"different content"));
}
#[test]
fn test_representation_unchanged_known_vector() {
let digest = ContentDigest::of(b"");
assert_eq!(digest.as_hex(), EMPTY_B3);
assert_eq!(digest.to_string(), EMPTY_B3);
assert_eq!(digest.as_hex().len(), 64);
}
#[test]
fn test_from_hex_accepts_canonical() {
let digest = ContentDigest::from_hex(EMPTY_B3).expect("valid digest");
assert_eq!(digest.as_hex(), EMPTY_B3);
assert_eq!(digest, ContentDigest::of(b""));
}
#[test]
fn test_from_hex_normalizes_uppercase() {
let digest = ContentDigest::from_hex(&EMPTY_B3.to_ascii_uppercase()).expect("valid hex");
assert_eq!(digest.as_hex(), EMPTY_B3);
}
#[test]
fn test_from_hex_rejects_wrong_length() {
let err = ContentDigest::from_hex("abc123").unwrap_err();
assert!(matches!(err, ScrybeError::InvalidDigest(_)));
let err = ContentDigest::from_hex(&format!("{EMPTY_B3}00")).unwrap_err();
assert!(matches!(err, ScrybeError::InvalidDigest(_)));
let err = ContentDigest::from_hex("").unwrap_err();
assert!(matches!(err, ScrybeError::InvalidDigest(_)));
}
#[test]
fn test_from_hex_rejects_non_hex() {
let bogus = "z".repeat(64);
let err = ContentDigest::from_hex(&bogus).unwrap_err();
assert!(matches!(err, ScrybeError::InvalidDigest(_)));
}
#[test]
fn test_from_str_parses() {
let digest: ContentDigest = EMPTY_B3.parse().expect("valid digest");
assert_eq!(digest.as_hex(), EMPTY_B3);
assert!("not-hex".parse::<ContentDigest>().is_err());
}
#[test]
fn test_serde_json_representation_is_plain_hex_string() {
let digest = ContentDigest::of(b"");
let json = serde_json::to_string(&digest).expect("serialize");
assert_eq!(json, format!("\"{EMPTY_B3}\""));
let back: ContentDigest = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, digest);
}
#[test]
fn test_serde_deserialize_rejects_invalid() {
assert!(serde_json::from_str::<ContentDigest>("\"nope\"").is_err());
}
#[test]
#[allow(deprecated)]
fn test_deprecated_content_id_alias_still_works() {
let old = ContentId::of(b"hello scrybe");
let new = ContentDigest::of(b"hello scrybe");
assert_eq!(old, new);
struct Probe;
impl ContentAddressable for Probe {
fn content_digest(&self) -> ContentDigest {
ContentDigest::of(b"probe")
}
}
assert_eq!(Probe.content_id(), Probe.content_digest());
}
}