use std::fmt;
use std::str::FromStr;
use ipld_core::cid::multihash::Multihash;
use ipld_core::cid::{Cid, Version};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::error::ContentError;
pub const DAG_CBOR_CODEC: u64 = 0x71;
pub const BLAKE3_HASH_CODE: u64 = 0x1e;
pub(crate) const BLAKE3_DIGEST_LEN: usize = 32;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct ContentId(Cid);
impl ContentId {
#[must_use]
pub fn from_canonical_bytes(bytes: &[u8]) -> Self {
let digest = blake3::hash(bytes);
Self::wrap_blake3_digest(*digest.as_bytes())
}
pub fn from_canonical_bytes_checked(bytes: &[u8]) -> Result<Self, ContentError> {
crate::canonical::ensure_canonical(bytes)?;
Ok(Self::from_canonical_bytes(bytes))
}
#[deprecated(
since = "0.1.1",
note = "use RawContentId::from_blake3_digest for a digest of opaque bytes (the honest \
profile), or ContentId::from_dag_cbor_digest when the digest is known to be \
over canonical dag-cbor"
)]
#[must_use]
pub fn from_blake3_content_digest(digest: [u8; BLAKE3_DIGEST_LEN]) -> Self {
Self::wrap_blake3_digest(digest)
}
#[must_use]
pub fn from_dag_cbor_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");
ContentId(Cid::new_v1(DAG_CBOR_CODEC, mh))
}
#[must_use]
pub fn as_cid(&self) -> &Cid {
&self.0
}
#[must_use]
pub fn digest_bytes(&self) -> [u8; BLAKE3_DIGEST_LEN] {
self.0
.hash()
.digest()
.try_into()
.expect("a ContentId multihash always carries a 32-byte BLAKE3 digest")
}
#[must_use]
pub fn digest_hex(&self) -> String {
let digest = self.digest_bytes();
let mut s = String::with_capacity(digest.len() * 2);
for b in digest {
s.push(char::from_digit((b >> 4) as u32, 16).expect("nibble < 16 is a hex digit"));
s.push(char::from_digit((b & 0x0f) as u32, 16).expect("nibble < 16 is a hex digit"));
}
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_profile(&cid)?;
Ok(ContentId(cid))
}
}
fn validate_profile(cid: &Cid) -> Result<(), ContentError> {
if cid.version() != Version::V1 {
return Err(ContentError::InvalidCidProfile {
reason: format!("expected CIDv1, got {:?}", cid.version()),
});
}
if cid.codec() != DAG_CBOR_CODEC {
return Err(ContentError::InvalidCidProfile {
reason: format!(
"expected dag-cbor codec 0x{DAG_CBOR_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 ContentId {
type Error = ContentError;
fn try_from(cid: Cid) -> Result<Self, Self::Error> {
validate_profile(&cid)?;
Ok(ContentId(cid))
}
}
impl From<ContentId> for Cid {
fn from(id: ContentId) -> Self {
id.0
}
}
impl fmt::Display for ContentId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(&self.0, f)
}
}
impl FromStr for ContentId {
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_profile(&cid)?;
Ok(ContentId(cid))
}
}
impl Serialize for ContentId {
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 ContentId {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
if deserializer.is_human_readable() {
let s = String::deserialize(deserializer)?;
s.parse::<ContentId>()
.map_err(<D::Error as serde::de::Error>::custom)
} else {
let cid = Cid::deserialize(deserializer)?;
validate_profile(&cid).map_err(<D::Error as serde::de::Error>::custom)?;
Ok(ContentId(cid))
}
}
}
#[cfg(test)]
mod cid_param_lock_tests {
use super::{ContentId, BLAKE3_DIGEST_LEN, BLAKE3_HASH_CODE, DAG_CBOR_CODEC};
use ipld_core::cid::Version;
fn empty_map_id() -> ContentId {
ContentId::from_canonical_bytes(&[0xa0])
}
#[test]
fn cid_params_are_literally_v1_dagcbor_blake3_32() {
let id = empty_map_id();
let cid = id.as_cid();
assert_eq!(cid.version(), Version::V1, "CID version is frozen at V1");
assert_eq!(cid.codec(), 0x71, "codec is frozen at dag-cbor (0x71)");
assert_eq!(
cid.hash().code(),
0x1e,
"multihash is frozen at BLAKE3 (0x1e)"
);
assert_eq!(
cid.hash().digest().len(),
32,
"BLAKE3 digest length is frozen at 32 bytes"
);
}
#[test]
fn frozen_constants_hold_their_documented_values() {
assert_eq!(DAG_CBOR_CODEC, 0x71);
assert_eq!(BLAKE3_HASH_CODE, 0x1e);
assert_eq!(BLAKE3_DIGEST_LEN, 32);
}
#[test]
fn cid_binary_prefix_is_frozen() {
let bytes = empty_map_id().to_bytes();
assert_eq!(
&bytes[..4],
&[0x01, 0x71, 0x1e, 0x20],
"CIDv1/dag-cbor/BLAKE3/len-32 binary prefix is frozen"
);
assert_eq!(
bytes.len(),
4 + BLAKE3_DIGEST_LEN,
"CID binary form is the 4-byte prefix plus the 32-byte digest"
);
}
}
#[cfg(test)]
#[allow(deprecated)] mod no_rehash_digest_tests {
use super::{ContentId, BLAKE3_DIGEST_LEN};
#[test]
fn from_dag_cbor_digest_is_byte_identical_to_deprecated_door() {
let d = *blake3::hash(b"any canonical dag-cbor").as_bytes();
assert_eq!(
ContentId::from_dag_cbor_digest(d),
ContentId::from_blake3_content_digest(d)
);
assert_eq!(
ContentId::from_dag_cbor_digest(d),
ContentId::from_canonical_bytes(b"any canonical dag-cbor")
);
}
use ipld_core::cid::Version;
fn sample_digest() -> [u8; BLAKE3_DIGEST_LEN] {
let mut d = [0u8; BLAKE3_DIGEST_LEN];
for (i, b) in d.iter_mut().enumerate() {
*b = (i as u8) + 1;
}
d
}
#[test]
fn produces_v1_dagcbor_blake3_cid_carrying_the_exact_digest() {
let d = sample_digest();
let id = ContentId::from_blake3_content_digest(d);
let cid = id.as_cid();
assert_eq!(cid.version(), Version::V1, "must be a CIDv1");
assert_eq!(cid.codec(), 0x71, "codec must be dag-cbor (0x71)");
assert_eq!(cid.hash().code(), 0x1e, "multihash must be BLAKE3 (0x1e)");
assert_eq!(
cid.hash().digest().len(),
BLAKE3_DIGEST_LEN,
"digest must be 32 bytes"
);
assert_eq!(
cid.hash().digest(),
&d,
"the CID must carry the caller's digest verbatim"
);
}
#[test]
fn does_not_rehash_the_digest() {
let d = sample_digest();
let wrapped = ContentId::from_blake3_content_digest(d);
let hashed_again = ContentId::from_canonical_bytes(&d);
assert_ne!(
wrapped, hashed_again,
"from_blake3_content_digest must NOT re-hash: wrapping d must differ \
from BLAKE3(d)"
);
assert_eq!(wrapped.as_cid().hash().digest(), &d);
assert_eq!(
hashed_again.as_cid().hash().digest(),
blake3::hash(&d).as_bytes(),
);
}
#[test]
fn digest_survives_a_byte_roundtrip() {
let d = sample_digest();
let id = ContentId::from_blake3_content_digest(d);
assert_eq!(id.as_cid().hash().digest(), &d);
let bytes = id.to_bytes();
assert_eq!(
&bytes[bytes.len() - BLAKE3_DIGEST_LEN..],
&d,
"the trailing 32 bytes of the CID binary form are the digest"
);
let reparsed = ContentId::from_bytes(&bytes).expect("CID bytes must reparse");
assert_eq!(reparsed, id);
assert_eq!(reparsed.as_cid().hash().digest(), &d);
}
#[test]
fn converges_with_from_canonical_bytes_when_caller_hashes_correctly() {
for content in [
&b""[..],
&[0xa0][..], b"the quick brown fox",
b"\x00\x01\x02\x03 arbitrary canonical bytes",
] {
let digest: [u8; BLAKE3_DIGEST_LEN] = blake3::hash(content).into();
let via_digest = ContentId::from_blake3_content_digest(digest);
let via_hash = ContentId::from_canonical_bytes(content);
assert_eq!(
via_digest, via_hash,
"from_blake3_content_digest(blake3(x)) must equal \
from_canonical_bytes(x)"
);
}
}
}
#[cfg(test)]
mod presentation_tests {
use super::{ContentId, BLAKE3_DIGEST_LEN};
fn empty_map_id() -> ContentId {
ContentId::from_canonical_bytes(&[0xa0])
}
const EMPTY_MAP_DIGEST_HEX: &str =
"1f94cbf313b3ce23257a7251ea0fc95a24556ea611e4f8f475e549971baedb02";
#[test]
fn display_is_frozen_base32_lower() {
let id = empty_map_id();
let s = id.to_string();
assert_eq!(
s, "bafyr4ia7stf7ge5tzyrsk6tskhva7sk2erkw5jqr4t4pi5pfjglrxlw3ai",
"Display must be the frozen base32-lower CID string"
);
assert!(s.starts_with('b'), "base32-lower multibase prefix is 'b'");
}
#[test]
fn display_fromstr_roundtrip_is_frozen() {
let id = empty_map_id();
let parsed: ContentId = id.to_string().parse().expect("base32 string must reparse");
assert_eq!(parsed, id, "Display -> FromStr must round-trip");
}
#[test]
fn digest_bytes_is_the_raw_32_byte_blake3_hash() {
let id = empty_map_id();
let d = id.digest_bytes();
assert_eq!(d.len(), BLAKE3_DIGEST_LEN);
assert_eq!(&d[..], id.as_cid().hash().digest());
assert_eq!(d, *blake3::hash(&[0xa0]).as_bytes());
}
#[test]
fn digest_hex_is_frozen_64_char_lower_hex_no_prefix() {
let id = empty_map_id();
let h = id.digest_hex();
assert_eq!(h.len(), 64, "digest_hex is 64 chars (32 bytes)");
assert!(!h.starts_with("0x"), "digest_hex carries no 0x prefix");
assert!(
h.chars()
.all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)),
"digest_hex is lowercase hex only"
);
assert_eq!(
h, EMPTY_MAP_DIGEST_HEX,
"frozen empty-map digest_hex drifted"
);
}
#[test]
fn digest_hex_is_hex_of_digest_bytes_not_of_the_cid() {
let id = empty_map_id();
let digest = id.digest_bytes();
let mut expected = String::new();
for b in digest {
expected.push_str(&format!("{b:02x}"));
}
assert_eq!(id.digest_hex(), expected);
let cid_bytes_hex: String = id.to_bytes().iter().map(|b| format!("{b:02x}")).collect();
assert!(
cid_bytes_hex.ends_with(&id.digest_hex()),
"the full-CID-bytes-hex ends with the bare-digest-hex"
);
assert!(
cid_bytes_hex.len() > id.digest_hex().len(),
"full-CID-bytes-hex is longer than bare-digest-hex (it has the envelope)"
);
assert_eq!(cid_bytes_hex, format!("01711e20{}", id.digest_hex()));
}
}
#[cfg(test)]
mod checked_input_tests {
use super::ContentId;
use crate::canonical::to_canonical_dagcbor;
use crate::error::ContentError;
#[test]
fn checked_accepts_canonical_bytes_and_matches_the_unchecked_door() {
for value in [
&serde_json::json!({}),
&serde_json::json!({"alpha": 1, "zeta": 26}),
&serde_json::json!({"name": "hello", "n": [1, 2, 3], "nested": {"a": 1}}),
&serde_json::json!([1, "two", [3, 4], null]),
] {
let ipld: ipld_core::ipld::Ipld =
serde_json::from_value((*value).clone()).expect("json -> ipld");
let canonical = to_canonical_dagcbor(&ipld).expect("encode canonical");
let via_checked =
ContentId::from_canonical_bytes_checked(&canonical).expect("canonical accepted");
let via_unchecked = ContentId::from_canonical_bytes(&canonical);
assert_eq!(
via_checked, via_unchecked,
"checked(canonical) must equal the unchecked id for the same bytes"
);
}
}
#[test]
fn always_canonical_path_equals_from_canonical_bytes_of_to_canonical_dagcbor() {
let ipld: ipld_core::ipld::Ipld = serde_json::from_value(serde_json::json!(
{"alpha": 1, "zeta": 26, "list": [true, false, null]}
))
.expect("json -> ipld");
let canonical = to_canonical_dagcbor(&ipld).expect("encode canonical");
let from_unchecked = ContentId::from_canonical_bytes(&canonical);
let from_checked =
ContentId::from_canonical_bytes_checked(&canonical).expect("canonical accepted");
assert_eq!(
from_checked, from_unchecked,
"from_canonical_bytes_checked(to_canonical_dagcbor(x)) == \
from_canonical_bytes(to_canonical_dagcbor(x))"
);
}
#[test]
fn checked_rejects_non_canonical_but_valid_cbor() {
let non_canonical = [0xa2, 0x62, 0x62, 0x62, 0x01, 0x61, 0x61, 0x02];
#[allow(deprecated)]
let decoded: ipld_core::ipld::Ipld =
crate::canonical::from_canonical_dagcbor(&non_canonical)
.expect("non-canonical bytes are still valid CBOR and decode");
let recanon = to_canonical_dagcbor(&decoded).expect("re-encode");
assert_ne!(
&recanon[..],
&non_canonical[..],
"fixture must actually be non-canonical"
);
let err = ContentId::from_canonical_bytes_checked(&non_canonical)
.expect_err("non-canonical CBOR must be rejected");
assert!(
matches!(err, ContentError::NonCanonical),
"non-canonical valid CBOR must map to ContentError::NonCanonical, got {err:?}"
);
}
#[test]
fn checked_rejects_indefinite_length_cbor() {
let indefinite = [0x9f, 0x01, 0x02, 0x03, 0xff];
let err = ContentId::from_canonical_bytes_checked(&indefinite)
.expect_err("indefinite-length CBOR must be rejected");
assert!(
matches!(
err,
ContentError::NonCanonical | ContentError::DecodingError { .. }
),
"indefinite-length CBOR must be rejected (NonCanonical or DecodingError), got {err:?}"
);
}
#[test]
fn checked_rejects_non_dagcbor_garbage() {
let garbage = [0xff, 0xff, 0xff, 0xff];
let err = ContentId::from_canonical_bytes_checked(&garbage)
.expect_err("non-dag-cbor garbage must be rejected");
assert!(
matches!(err, ContentError::DecodingError { .. }),
"non-dag-cbor garbage must map to ContentError::DecodingError, got {err:?}"
);
}
#[test]
fn unchecked_still_mints_an_id_for_non_canonical_bytes() {
let non_canonical = [0xa2, 0x62, 0x62, 0x62, 0x01, 0x61, 0x61, 0x02];
let misleading = ContentId::from_canonical_bytes(&non_canonical);
assert_eq!(
misleading.digest_bytes(),
*blake3::hash(&non_canonical).as_bytes(),
"the unchecked door hashes exactly the bytes it was given"
);
assert!(ContentId::from_canonical_bytes_checked(&non_canonical).is_err());
#[allow(deprecated)]
let decoded: ipld_core::ipld::Ipld =
crate::canonical::from_canonical_dagcbor(&non_canonical).expect("valid CBOR");
let canonical = to_canonical_dagcbor(&decoded).expect("re-encode");
let honest = ContentId::from_canonical_bytes(&canonical);
assert_ne!(
misleading, honest,
"minting over non-canonical bytes names a different id than the \
canonical form would — exactly the hazard from_canonical_bytes warns of"
);
}
}
#[cfg(test)]
mod profile_validation_tests {
use super::*;
use ipld_core::cid::multihash::Multihash;
use ipld_core::cid::Cid;
const SHA2_256: u64 = 0x12;
const RAW_CODEC: u64 = 0x55;
fn blake3_mh(digest: &[u8]) -> Multihash<64> {
Multihash::wrap(BLAKE3_HASH_CODE, digest).expect("wrap blake3")
}
fn cidv0_sha256() -> Cid {
Cid::new_v0(Multihash::wrap(SHA2_256, &[7u8; 32]).expect("wrap sha256")).expect("v0")
}
fn v1_non_dagcbor_codec() -> Cid {
Cid::new_v1(RAW_CODEC, blake3_mh(&[7u8; 32]))
}
fn v1_sha256_hash() -> Cid {
Cid::new_v1(
DAG_CBOR_CODEC,
Multihash::wrap(SHA2_256, &[7u8; 32]).expect("wrap sha256"),
)
}
fn v1_short_digest() -> Cid {
Cid::new_v1(DAG_CBOR_CODEC, blake3_mh(&[7u8; 16]))
}
fn assert_all_ingress_reject(cid: Cid) {
assert!(
matches!(
ContentId::try_from(cid),
Err(ContentError::InvalidCidProfile { .. })
),
"TryFrom<Cid> must reject a foreign CID"
);
assert!(
matches!(
ContentId::from_bytes(&cid.to_bytes()),
Err(ContentError::InvalidCidProfile { .. })
),
"from_bytes must reject a foreign CID envelope"
);
assert!(
matches!(
cid.to_string().parse::<ContentId>(),
Err(ContentError::InvalidCidProfile { .. })
),
"FromStr must reject a foreign CID string"
);
}
#[test]
fn cidv0_sha256_is_rejected() {
assert_all_ingress_reject(cidv0_sha256());
}
#[test]
fn v1_non_dagcbor_codec_is_rejected() {
assert_all_ingress_reject(v1_non_dagcbor_codec());
}
#[test]
fn v1_sha256_hash_is_rejected() {
assert_all_ingress_reject(v1_sha256_hash());
}
#[test]
fn v1_short_digest_is_rejected() {
assert_all_ingress_reject(v1_short_digest());
}
#[test]
fn a_foreign_cid_link_in_dagcbor_is_rejected_on_deserialize() {
let foreign = v1_sha256_hash();
let bytes = serde_ipld_dagcbor::to_vec(&foreign).expect("encode a tag-42 link");
let decoded: Result<ContentId, _> = serde_ipld_dagcbor::from_slice(&bytes);
assert!(
decoded.is_err(),
"a foreign CID link must not deserialize as a ContentId"
);
}
#[test]
fn an_on_profile_id_passes_every_ingress_path() {
let id = ContentId::from_canonical_bytes(&[0xa0]); assert_eq!(ContentId::try_from(*id.as_cid()).expect("try_from"), id);
assert_eq!(
ContentId::from_bytes(&id.to_bytes()).expect("from_bytes"),
id
);
assert_eq!(id.to_string().parse::<ContentId>().expect("parse"), id);
assert_eq!(id.digest_bytes().len(), BLAKE3_DIGEST_LEN);
}
}