use crate::content_id::{BLAKE3_DIGEST_LEN, BLAKE3_HASH_CODE};
use crate::error::ContentError;
use core::fmt;
use core::str::FromStr;
use ipld_core::cid::multihash::Multihash;
use ipld_core::cid::{Cid, Version};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[allow(unused_imports)]
use crate::content_id::ContentId;
pub const RAW_CODEC: u64 = 0x55;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct RawContentId(Cid);
impl RawContentId {
#[must_use]
pub fn from_content(content: &[u8]) -> Self {
let digest = blake3::hash(content);
Self::wrap_blake3_digest(*digest.as_bytes())
}
#[must_use]
pub fn from_blake3_digest(digest: [u8; BLAKE3_DIGEST_LEN]) -> Self {
Self::wrap_blake3_digest(digest)
}
fn wrap_blake3_digest(digest: [u8; BLAKE3_DIGEST_LEN]) -> Self {
let mh = Multihash::wrap(BLAKE3_HASH_CODE, &digest)
.expect("BLAKE3 digest is 32 bytes and always fits a 64-byte multihash");
RawContentId(Cid::new_v1(RAW_CODEC, mh))
}
#[must_use]
pub fn verify(&self, content: &[u8]) -> bool {
Self::from_content(content) == *self
}
#[must_use]
pub fn as_cid(&self) -> &Cid {
&self.0
}
#[must_use]
pub fn digest_bytes(&self) -> [u8; BLAKE3_DIGEST_LEN] {
let mut out = [0u8; BLAKE3_DIGEST_LEN];
out.copy_from_slice(self.0.hash().digest());
out
}
#[must_use]
pub fn digest_hex(&self) -> String {
let mut s = String::with_capacity(BLAKE3_DIGEST_LEN * 2);
for b in self.digest_bytes() {
s.push_str(&format!("{b:02x}"));
}
s
}
#[must_use]
pub fn to_bytes(&self) -> Vec<u8> {
self.0.to_bytes()
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self, ContentError> {
let cid = Cid::read_bytes(bytes).map_err(|e| ContentError::InvalidCid {
reason: e.to_string(),
source: Box::new(e),
})?;
validate_raw_profile(&cid)?;
Ok(RawContentId(cid))
}
}
pub(crate) fn validate_raw_profile(cid: &Cid) -> Result<(), ContentError> {
if cid.version() != Version::V1 {
return Err(ContentError::InvalidCidProfile {
reason: format!("expected CIDv1, got {:?}", cid.version()),
});
}
if cid.codec() != RAW_CODEC {
return Err(ContentError::InvalidCidProfile {
reason: format!(
"expected raw codec 0x{RAW_CODEC:x}, got 0x{:x}",
cid.codec()
),
});
}
if cid.hash().code() != BLAKE3_HASH_CODE {
return Err(ContentError::InvalidCidProfile {
reason: format!(
"expected BLAKE3 multihash 0x{BLAKE3_HASH_CODE:x}, got 0x{:x}",
cid.hash().code()
),
});
}
let digest_len = cid.hash().digest().len();
if digest_len != BLAKE3_DIGEST_LEN {
return Err(ContentError::InvalidCidProfile {
reason: format!("expected a {BLAKE3_DIGEST_LEN}-byte digest, got {digest_len} bytes"),
});
}
Ok(())
}
impl TryFrom<Cid> for RawContentId {
type Error = ContentError;
fn try_from(cid: Cid) -> Result<Self, Self::Error> {
validate_raw_profile(&cid)?;
Ok(RawContentId(cid))
}
}
impl From<RawContentId> for Cid {
fn from(id: RawContentId) -> Self {
id.0
}
}
impl fmt::Display for RawContentId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(&self.0, f)
}
}
impl FromStr for RawContentId {
type Err = ContentError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let cid = Cid::from_str(s).map_err(|e| ContentError::InvalidCid {
reason: e.to_string(),
source: Box::new(e),
})?;
validate_raw_profile(&cid)?;
Ok(RawContentId(cid))
}
}
impl Serialize for RawContentId {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
if serializer.is_human_readable() {
serializer.serialize_str(&self.to_string())
} else {
self.0.serialize(serializer)
}
}
}
impl<'de> Deserialize<'de> for RawContentId {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
if deserializer.is_human_readable() {
let s = String::deserialize(deserializer)?;
s.parse::<RawContentId>()
.map_err(<D::Error as serde::de::Error>::custom)
} else {
let cid = Cid::deserialize(deserializer)?;
RawContentId::try_from(cid).map_err(<D::Error as serde::de::Error>::custom)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::content_id::ContentId;
#[test]
fn profile_bytes_are_cidv1_raw_blake3() {
let id = RawContentId::from_content(b"");
let bytes = id.to_bytes();
assert_eq!(&bytes[..4], &[0x01, 0x55, 0x1e, 0x20]);
assert_eq!(bytes.len(), 4 + 32);
assert_eq!(id.as_cid().codec(), RAW_CODEC);
assert_eq!(id.as_cid().hash().code(), BLAKE3_HASH_CODE);
}
#[test]
fn from_content_and_from_digest_converge() {
let content = b"hello, raw world";
let via_content = RawContentId::from_content(content);
let via_digest = RawContentId::from_blake3_digest(*blake3::hash(content).as_bytes());
assert_eq!(via_content, via_digest);
assert!(via_content.verify(content));
assert!(!via_content.verify(b"other"));
}
#[test]
fn display_is_base32_lower_and_roundtrips() {
let id = RawContentId::from_content(b"abc");
let s = id.to_string();
assert!(
s.starts_with('b'),
"base32-lower CIDv1 strings start with 'b': {s}"
);
assert_eq!(s.parse::<RawContentId>().unwrap(), id);
assert_eq!(RawContentId::from_bytes(&id.to_bytes()).unwrap(), id);
}
#[test]
fn same_digest_different_profile_is_a_different_identity() {
let digest = *blake3::hash(b"same bytes").as_bytes();
let raw = RawContentId::from_blake3_digest(digest);
let dag = ContentId::from_dag_cbor_digest(digest);
assert_eq!(raw.digest_bytes(), dag.digest_bytes());
assert_eq!(raw.digest_hex(), dag.digest_hex());
assert_ne!(raw.to_bytes(), dag.to_bytes());
assert_ne!(raw.to_string(), dag.to_string());
assert!(matches!(
RawContentId::from_bytes(&dag.to_bytes()),
Err(ContentError::InvalidCidProfile { .. })
));
assert!(matches!(
ContentId::from_bytes(&raw.to_bytes()),
Err(ContentError::InvalidCidProfile { .. })
));
assert!(matches!(
dag.to_string().parse::<RawContentId>(),
Err(ContentError::InvalidCidProfile { .. })
));
assert!(RawContentId::try_from(*dag.as_cid()).is_err());
assert!(ContentId::try_from(*raw.as_cid()).is_err());
}
#[test]
fn foreign_cids_are_rejected() {
let mh = Multihash::wrap(0x12, &[0u8; 32]).unwrap();
let sha = Cid::new_v1(RAW_CODEC, mh);
assert!(matches!(
RawContentId::try_from(sha),
Err(ContentError::InvalidCidProfile { .. })
));
assert!(matches!(
RawContentId::from_bytes(b"not a cid"),
Err(ContentError::InvalidCid { .. })
));
assert!(matches!(
"not a cid".parse::<RawContentId>(),
Err(ContentError::InvalidCid { .. })
));
}
#[test]
fn serde_human_readable_is_string_and_binary_is_link() {
let id = RawContentId::from_content(b"serde");
let json = serde_json::to_string(&id).unwrap();
assert_eq!(json, format!("\"{id}\""));
assert_eq!(serde_json::from_str::<RawContentId>(&json).unwrap(), id);
let cbor = crate::canonical::to_canonical_dagcbor(&id).unwrap();
assert_eq!(&cbor[..2], &[0xd8, 0x2a]);
let back: RawContentId = crate::canonical::from_canonical_dagcbor_checked(&cbor).unwrap();
assert_eq!(back, id);
let dag = ContentId::from_dag_cbor_digest(id.digest_bytes());
let cbor_dag = crate::canonical::to_canonical_dagcbor(&dag).unwrap();
let err = crate::canonical::from_canonical_dagcbor_checked::<RawContentId>(&cbor_dag)
.expect_err("a dag-cbor link must not deserialize as a RawContentId");
assert!(
matches!(err, ContentError::DecodingError { .. }),
"the profile must be refused at the serde BOUNDARY (DecodingError), not \
caught later by the re-encode comparison, got {err:?}"
);
}
}