Skip to main content

content-addressable

Data carries its own proof of integrity, intrinsically.

IPLD-native content addressing for Rust and Python. A content address is derived from the data itself, not assigned by an authority — give someone the bytes and the address, and they can recompute the address and know, with no trusted third party, that the bytes are exactly what the address names. The proof travels with the data.

This crate is deliberately small and honest: it is the instrument, not the sky.

It speaks the multiformats / IPLD stack, so its artifacts interoperate with the wider content-addressed world (IPFS, IPLD, libp2p). The crate mints exactly two profiles, and the Rust/Python type says which:

Type CID Codec Multihash Digest Names
ContentId v1 DAG-CBOR (0x71) BLAKE3 (0x1e) 32 bytes a canonical structured value (encoding: canonical DAG-CBOR — strict key order, definite lengths, tag-42 links)
RawContentId v1 raw (0x55) BLAKE3 (0x1e) 32 bytes an opaque byte string — a file, a chunk, a binary, a payload

The profile is semantic, not cosmetic. Codec + multihash + digest jointly constitute identity: RawContentId(x) and ContentId(x) are different identities even when their 32 digest bytes are the same, they never compare equal, and each type's parsers reject the other's CIDs. A third type, ClassifiedCid { Content | Raw | Foreign(ForeignCid) }, can carry and compare any well-formed CID (a sha2-256 REAPI digest, a CIDv0, …) without this crate ever minting one — algorithm agility in the verifier, no algorithm ambiguity in the minter. Decision record: docs/adr/0003.

ClassifiedCid is canonical: the three profiles are pairwise disjoint and jointly total over well-formed CIDs, and ForeignCid's constructors reject a recognized profile — so every CID has exactly one representation, and no variant can hold a CID outside its own profile, including through Deserialize (the variant is derived from the bytes, never trusted from the wire). It is named Classified, not Verified, because it proves structural validity and profile membership — not that content matches a digest.

Rust is the core implementation; the Python package is a PyO3 binding over that same Rust core, so an id computed in Python is byte-identical to the one Rust computes for the same canonical IPLD value.

Status & stability

The package is 0.1.0 — the first release that freezes the core contract. The core byte/wire and API contracts are locked for the whole 0.1.x line (changing any is a breaking release outside 0.1.x), while the optional unstable-merkle / unstable-store features are explicitly still moving and are named to say so.

Surface Default Stability
ContentId, canonical encoding, core errors, presentation, MSRV Yes Frozen for 0.1.x — changing any is a breaking release outside 0.1.x
RawContentId, ClassifiedCid, ForeignCid (added #84) Yes Stable, additive — the raw profile's bytes are fixed by the CID spec (CIDv1 · raw · BLAKE3-256) and pinned cross-language by tests/raw_vectors.json; the ContentId freeze is untouched
Python core parity Separate package Same core byte profiles
unstable-merkle feature No Experimental — serialized node bytes NOT frozen
unstable-store feature No Experimental — trait/API surface NOT frozen (no new wire format of its own)
unstable-legacy feature No Experimental, shrinking — explicit edge parsers for legacy identifier dialects (kyln envelope-hex, nessie <algo>:<hex>, bare BLAKE3 hex); exist to end those dialects
unstable-migration feature No ExperimentalIdentityMigration record (from → to, reason); field names NOT frozen

Details and rationale: docs/STABILITY.md.

Installation

Rust:

[dependencies]
content-addressable = "0.1.0"

With the optional (default-off) features:

content-addressable = { version = "0.1.0", features = ["unstable-merkle"] }
content-addressable = { version = "0.1.0", features = ["unstable-store"] }
content-addressable = { version = "0.1.0", features = ["unstable-merkle", "unstable-store"] }

Python:

pip install content-addressable

The PyPI distribution is content-addressable (hyphen); the import name is content_addressable (underscore):

import content_addressable

Rust quick start

Implement ContentAddressable by providing canonical_form; content_id, verify, and ensure_content_id come for free:

use content_addressable::{canonical, ContentAddressable, ContentError};
use serde::Serialize;

#[derive(Serialize)]
struct Record {
    name: String,
}

impl ContentAddressable for Record {
    fn canonical_form(&self) -> Result<Vec<u8>, ContentError> {
        canonical::to_canonical_dagcbor(self)
    }
}

let record = Record { name: "alpha".into() };

let id = record.content_id()?;          // a CIDv1 (DAG-CBOR + BLAKE3)
assert!(record.verify(&id)?);           // self-certifying: re-derive and compare
println!("{id}");                       // "bafyr4i…" — the canonical text form
# Ok::<(), ContentError>(())

verify returns Ok(false) on a mismatch; its strict sibling ensure_content_id returns Err(ContentError::VerificationFailed) instead. The secondary digest and binary presentation forms are in Presentation forms.

Python quick start

The Python face exposes the same byte profile, but not the Rust ContentAddressable trait or verify — you canonicalize a native Python value and take its content_id directly. This block is mirrored by tests/test_readme.py (every call identical), so CI's python job proves it still works:

from content_addressable import (
    ContentId, content_id,
    to_canonical_dagcbor, from_canonical_dagcbor,
)

# A value's content id (CIDv1, DAG-CBOR + BLAKE3). Key order is irrelevant.
record = {"name": "alpha", "attrs": {}}
cid = content_id(record)

assert str(cid).startswith("b")                # base32-lower multibase text
assert len(cid.digest_hex()) == 64             # 64-char bare-digest-hex
assert isinstance(to_canonical_dagcbor(record), bytes)

# Canonical bytes round-trip; equal values -> equal bytes -> equal ids.
raw = to_canonical_dagcbor(record)
assert content_id(record) == ContentId.from_canonical_bytes(raw)
assert from_canonical_dagcbor(raw) == record
assert content_id({"attrs": {}, "name": "alpha"}) == cid  # order-independent

# Parse an id back from its text / binary forms.
assert ContentId.parse(str(cid)) == cid
assert ContentId.from_bytes(cid.to_bytes()) == cid

# Wrap an already-computed 32-byte BLAKE3 digest with NO re-hash.
assert ContentId.from_blake3_content_digest(cid.digest_bytes()) == cid
assert len(cid.digest_bytes()) == 32           # the raw BLAKE3 hash

ContentId implements __eq__ / __hash__, so an id is usable as a dict key or set member.

Choosing a construction path

Prefer the safe path. from_canonical_bytes is fast but carries a real precondition — it is not universally safe.

Use case API (Rust / Python) Contract
Hash a normal value value.content_id() / content_id(value) Preferred safe path
Encode a value to bytes canonical::to_canonical_dagcbor(v) / to_canonical_dagcbor(v) Produces canonical DAG-CBOR
Accept foreign / untrusted bytes Rust: ContentId::from_canonical_bytes_checked(b) · Python: no single checked constructor yet Validates DAG-CBOR canonicality; errors on non-canonical
Hash already-trusted canonical bytes ContentId::from_canonical_bytes(b) Unchecked precondition: caller asserts b is canonical DAG-CBOR
Identify opaque bytes (a file, chunk, binary, payload) RawContentId::from_content(b) / RawContentId.from_content(b) Hashes the bytes; nothing to get wrong — the bytes are the content
Wrap an existing BLAKE3 digest of opaque bytes RawContentId::from_blake3_digest(d) / RawContentId.from_blake3_digest(d) No rehash; the honest home of the no-rehash bridge (byte-identical to kyln raw CIDs / bare blake3 digests)
Wrap an existing BLAKE3 digest known to be over canonical DAG-CBOR ContentId::from_dag_cbor_digest(d) / ContentId.from_dag_cbor_digest(d) No rehash; the name asserts the precondition. from_blake3_content_digest is deprecated in its favor (#84): it stamped DAG-CBOR on a digest it could not know came from DAG-CBOR
Hold a CID you did not mint (REAPI sha2-256, CIDv0, …) ClassifiedCid::from_str / ClassifiedCid::from_bytes / ClassifiedCid::from_cid Classifies as Content / Raw / Foreign; foreign ids are carried and compared, never minted

Presentation forms

ContentId and RawContentId each name the same four presentation forms, with the same accessor names meaning the same things, so callers can't confuse them; each is frozen for ContentId (changing any is a breaking release outside 0.1.x) and fixed by the CID spec for RawContentId:

Form Rust Python What it is
Canonical text Display / to_string() str(id) multibase base32-lower (b…) — the IPLD-canonical CID string
Binary envelope to_bytes() / from_bytes() to_bytes() / from_bytes() the full CID binary form (version + codec + multihash + digest)
Bare digest digest_bytes() -> [u8; 32] digest_bytes() -> bytes the raw 32-byte BLAKE3 hash, no envelope
Bare-digest-hex digest_hex() -> String digest_hex() -> str lower-hex of the 32-byte digest (64 chars, no prefix)

Display is the inverse of FromStr for base32-lower, and that round-trip is frozen and tested. Full CID bytes can be hex-encoded by a caller directly (hex::encode(id.to_bytes())) — the crate deliberately does not bless a second "hex" method; see docs/STABILITY.md for why.

Two rules follow from "the profile is semantic": emit only the canonical text form (base32-lower b…), and compare identities as typed CID bytes — never as digest_hex() (identical across the two profiles for the same digest) and never as text. Legacy dialects (kyln envelope-hex, nessie blake3:<hex> / sha2-256:<hex>, bare BLAKE3 hex) are read only through the explicit unstable-legacy adapters (legacy::kyln::parse, legacy::nessie::parse, legacy::bare_blake3::parse) at a system's edge; from_str never learns them.

Experimental features

All unstable-* features are default-off and exercised in CI via --all-features. Do not depend on the unstable-merkle node bytes yet.

unstable-legacy — edge adapters that end the old dialects

legacy::kyln::parse(envelope_hex) -> RawContentId (kyln's hand-rolled CIDv1 is byte-identical to CIDv1 raw/BLAKE3), legacy::nessie::parse("<algo>:<hex>") -> ClassifiedCid (blake3Raw, sha2-256Foreign), and legacy::bare_blake3::parse(hex) -> RawContentId. Parsers only — canonical output stays base32-lower. Expected to shrink as consumers migrate.

unstable-migration — identity changes are stated, never implied

IdentityMigration { from: ClassifiedCid, to: ClassifiedCid, reason: MigrationKind } is a content-addressed record that one identity superseded another (Recanonicalized / Reprofiled / HashRotated). Re-canonicalizing a value or re-profiling a digest is an identity migration, and the record — not an equality — is what provenance follows. Who may assert one, and how it is signed, is a consumer concern.

Its fields are private with one construction path, IdentityMigration::new, which enforces that to is mintable and that from != to; Deserialize routes through that same constructor and rejects unknown fields, so a decoded record satisfies exactly the invariants a constructed one does.

merkle — content-addressed DAG nodes

MerkleNode<T> is a payload: T plus parents: BTreeSet<ContentId>; its id is derived from both the payload and the parent links, so a root id plus the node bytes determines the whole DAG. Because parents are a BTreeSet, they are deduplicated and ordered by content-derived Ord — equal parent sets always produce equal bytes regardless of insertion order, and each parent serializes as a real DAG-CBOR tag-42 link.

use content_addressable::merkle::MerkleNode; // feature = "unstable-merkle"

let root = MerkleNode::genesis("hello");
let root_id = root.id()?;
let child = MerkleNode::new("world", [root_id]);
assert!(child.parents().contains(&root_id));
# Ok::<(), content_addressable::ContentError>(())

The serialized node layout is experimental and NOT frozen — pinning it (Merkle conformance vectors) is post-0.1.0 work.

store — the CID-addressed node store seam

A narrow, backend-agnostic seam: get/put by ContentId, with a verified read path the extension-trait implementation establishes. The pieces:

  • NodeStore — the raw backend seam (two dumb ops: get_unverified, insert). Backends implement only this.
  • NodeStoreExt — blanket-implemented, sealed-by-coherence verified operations (get, get_node, put, put_checked, …). A backend cannot re-implement them.
  • VerifiedStore<B> — the recommended capability-safe facade: it exposes only the verified operations (dispatched via UFCS), so a backend's own inherent method cannot intercept a call made through it. Use this unless you have a reason to drop to raw ops.
  • MemoryStore — the grow-only in-memory reference backend.
  • AddressedBytes — an unforgeable, address-consistent (id, bytes) pair; it is the only thing insert accepts, so a backend can't be handed a mismatched pair.

Typed writes/reads are the strict doors:

use content_addressable::store::{MemoryStore, VerifiedStore};
use content_addressable::{canonical, ContentAddressable, ContentError};
use serde::{Deserialize, Serialize};

#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct Record {
    name: String,
}

impl ContentAddressable for Record {
    fn canonical_form(&self) -> Result<Vec<u8>, ContentError> {
        canonical::to_canonical_dagcbor(self)
    }
}

let mut store = VerifiedStore::new(MemoryStore::new());
let record = Record { name: "alpha".into() };

let id = store.put_node(&record)?;               // strict: rejects non-canonical
let recovered: Record = store.get_node(&id)?;    // identity-preserving typed read
assert_eq!(record, recovered);
# Ok::<(), content_addressable::store::StoreError>(())
  • put_node strictly validates canonical DAG-CBOR before insertion; put is unchecked with respect to canonicality (put_checked / put_node are the strict doors).
  • get_node performs an identity-preserving typed read (decode, re-encode, and require the value to be the one named by the id). Raw get proves only that the returned bytes hash to the requested CID — not that they are canonical.

Trust boundary. The seam derives addresses on write and verifies them on read (seam theorems). Successful persistence, durability, no-rebind, and grow-only behavior are backend obligations (backend refinement laws), not seam theorems — MemoryStore discharges its documented in-memory obligations. The formal Lean/TLA+ artifacts are deferred proof targets (tracked in #71); the store module docs in src/store.rs carry the full proof-obligation catalog. The store trait/API is experimental and NOT frozen.

Stability details

The frozen 0.1.x contracts — CID profile, presentation, serde representation, error policy, verify/ensure_content_id, crate-root exports, MSRV/edition, the no-rehash digest bridge, and the experimental-feature exclusions — are recorded in docs/STABILITY.md, with issue provenance. Treat the frozen surfaces as durable.

Development

just check            # the local gate: fmt + clippy + test + docs + leaf-deps

A pre-push hook runs the same checks; the individual cargo fmt / cargo clippy / cargo test --all-features steps work directly too.

Releasing

Tag-driven; see RELEASING.md for the wheel matrix, PyPI Trusted Publishing, and crates.io steps.

License

Apache-2.0.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

content_addressable-0.1.1.tar.gz (187.9 kB view details)

Uploaded Source

Built Distributions

If you're not sure about the file name format, learn more about wheel file names.

content_addressable-0.1.1-cp39-abi3-win_amd64.whl (285.1 kB view details)

Uploaded CPython 3.9+Windows x86-64

content_addressable-0.1.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (441.4 kB view details)

Uploaded CPython 3.9+manylinux: glibc 2.17+ x86-64

content_addressable-0.1.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl (427.1 kB view details)

Uploaded CPython 3.9+manylinux: glibc 2.17+ ARM64

content_addressable-0.1.1-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl (768.6 kB view details)

Uploaded CPython 3.9+macOS 10.12+ universal2 (ARM64, x86-64)macOS 10.12+ x86-64macOS 11.0+ ARM64

File details

Details for the file content_addressable-0.1.1.tar.gz.

File metadata

  • Download URL: content_addressable-0.1.1.tar.gz
  • Upload date:
  • Size: 187.9 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for content_addressable-0.1.1.tar.gz
Algorithm Hash digest
SHA256 af5db9c96b8d5aec6abd3a06fe7977d6bedfcd38d7706709128f9992ba5dfb67
MD5 2e47349a5e10934cbe20d188afe770cb
BLAKE2b-256 a5432fb7937b2a183c64a65aeb976f6c08d7a0b388c361f7bcf9ca11372ad102

See more details on using hashes here.

Provenance

The following attestation bundles were made for content_addressable-0.1.1.tar.gz:

Publisher: release.yml on hartsock/content-addressable

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file content_addressable-0.1.1-cp39-abi3-win_amd64.whl.

File metadata

File hashes

Hashes for content_addressable-0.1.1-cp39-abi3-win_amd64.whl
Algorithm Hash digest
SHA256 7b772ab347efbc9b6211cb6fa988da289255a22e8f794e5af79ad01beb4e3059
MD5 3808ac30dcad49461d942d0a498df868
BLAKE2b-256 26ab9f53251eed09b265b252ab8741e120c47f7815cdb8846f0e5ec9250473a5

See more details on using hashes here.

Provenance

The following attestation bundles were made for content_addressable-0.1.1-cp39-abi3-win_amd64.whl:

Publisher: release.yml on hartsock/content-addressable

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file content_addressable-0.1.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.

File metadata

File hashes

Hashes for content_addressable-0.1.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 cb2b07929f0568cf615066cdc707a5cf2f6465cacc6b20326b60f15ecb2964c4
MD5 5a55c6646f4e01ca1963d9fc423851f2
BLAKE2b-256 fc4e28f05fba55d23293086aaedcf50b8355925cfa6afa37a2ef5313dca17ba1

See more details on using hashes here.

Provenance

The following attestation bundles were made for content_addressable-0.1.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl:

Publisher: release.yml on hartsock/content-addressable

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file content_addressable-0.1.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.

File metadata

File hashes

Hashes for content_addressable-0.1.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Algorithm Hash digest
SHA256 8faeb9ea129fad3e44e46fa9a610a9e27f3d44feb557985ff2fdcb5b84b9a163
MD5 c9928ed2487d2ef25b57ae8293dcf2fe
BLAKE2b-256 553de70acb66267a02d0589886e6e5365d77fd6afa027fdb778b52405e1bfb4b

See more details on using hashes here.

Provenance

The following attestation bundles were made for content_addressable-0.1.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl:

Publisher: release.yml on hartsock/content-addressable

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file content_addressable-0.1.1-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl.

File metadata

File hashes

Hashes for content_addressable-0.1.1-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl
Algorithm Hash digest
SHA256 6c990657138f5e0e18a1f3d1285b40915fa7374d749a1c4e47e5386880e1d271
MD5 58e8de8edd52019385d2d8088fbae109
BLAKE2b-256 b3a9346aa01eb546edc58a39d68cb177f3af12ca4e4e1c63a65e1e0074bace0f

See more details on using hashes here.

Provenance

The following attestation bundles were made for content_addressable-0.1.1-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl:

Publisher: release.yml on hartsock/content-addressable

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page