Content addressing identifies a piece of data by a cryptographic hash of its contents rather than by a name or a location. Two copies of the same bytes get the same identifier wherever they came from, a single changed bit produces a completely different one, and because the identifier is derived from the data itself it works as a lookup key and an integrity check at the same time.
I keep running into content addressing in package management, usually solving one problem at one layer, and I’ve been collecting the places it appears. A package manager content-addressed end to end would have its registry index, its package metadata, every released artifact, and the files inside them all named by hash, so any of it could be fetched from any source and verified locally. That idea has been floating around for as long as I’ve been paying attention, and several of the systems below get a long way towards it without any one of them yet covering the whole stack. This is a survey of the pieces as they exist today: what gets hashed, and what each hash is used for.
What gets hashed
Individual files within a package. pnpm keeps a shared on-disk store, normally one per filesystem, where every file from every installed package lives once, named by its content hash, and each project’s node_modules is built from hard links into it, so ten projects depending on the same version of React share one physical copy of every file. OCI registries store each image layer as a blob keyed by its sha256 digest and reference layers from manifests by digest, so pushing an image whose base layers already exist on the registry transfers only the new ones. The same blob store and manifest format now carries Helm charts, WebAssembly modules, and arbitrary files via ORAS, which makes an OCI registry a general-purpose content-addressed package store as much as a container host.
The remote execution API used by Bazel and Buck stores build inputs and outputs in a content-addressable store of individual file blobs, and ostree does the same for whole operating system trees underneath Flatpak and rpm-ostree. nix store optimise walks an existing Nix store and replaces duplicate files across unrelated packages with hard links to a single copy.
A single distributed artifact. One tarball, gem, wheel, jar, or crate, one hash, recorded in almost every lockfile format: npm and Yarn’s integrity field in SRI notation, Cargo.lock’s checksum, go.sum, composer.lock, mix.lock, poetry.lock, uv.lock, pubspec.lock, and Bundler’s CHECKSUMS block in Gemfile.lock. Registries publish the same hash alongside the artifact so a client with no prior state has a reference to check against: PyPI’s #sha256= URL fragments and PEP 691 hashes objects, Maven’s .sha1 and .sha256 sidecar files, the crates.io index cksum, npm’s dist.integrity, Hex’s inner and outer checksums, conda’s per-package sha256 in each channel’s repodata.json.
The same level also covers Homebrew’s per-bottle sha256, the hash listed for each .deb in a Debian Packages file, Subresource Integrity attributes on <script> tags, and the expected hash passed to Nix’s fetchurl and other fixed-output derivations. The digest of an OCI image manifest sits here too, with the difference that it is routinely used as the fetch key, as in alpine@sha256:…, as well as a check after fetching by tag.
A resolved dependency graph. A lockfile taken as a whole records one selected dependency resolution across many packages: the exact set of artifacts one project resolved to at one moment. Nix’s flake.lock is the most explicit form, recording a content hash for every flake input transitively. A Cargo.lock, Gemfile.lock, or package-lock.json does the same in its own format, and committing one to a git repository makes the resolution itself content-addressed by way of the containing commit, which npm ci, bundle install --frozen, and cargo build --locked then treat as authoritative.
Every version of one package. A hash over the complete list of releases for a single package name at a point in time snapshots that package’s publishing history. A TUF repository can express this as a delegated targets file scoped to one package, listing each release with its artifact hash, with the file’s own hash recorded in the snapshot metadata above it. gittuf records every state a git reference has held as a signed, hash-chained reference state log, which for a repository that tags its releases is a verifiable history of every version it has published.
Hackage’s append-only 01-index.tar accumulates per-package entries that build the same record over time. The crates.io git index keeps one file per crate listing all published versions, so the git blob hash of that file is also such a snapshot, though that is a side effect of the index living in a git repository; since the sparse protocol became Cargo’s default in 1.70 most clients fetch per-crate JSON over HTTP and never see the git form.
Every package in a registry. A hash over the full index of a registry at a point in time pins the complete set of packages and versions available from it. Debian’s Release file contains hashes of the Packages files for each component and architecture, which in turn list a hash for every .deb, giving a three-level chain from one signed file down to every byte in the archive; Alpine’s APKINDEX and RPM’s repomd.xml follow the same pattern.
Registries that keep their index in git get this property from git itself: the HEAD commit of the crates.io index, a Homebrew tap, nixpkgs, or a Guix channel is a content-addressed snapshot of every package definition it holds. TUF expresses the same thing as snapshot.json and timestamp.json metadata, and Hackage layers the hackage-security TUF implementation over its index tarball.
Metadata about artifacts. Signatures, attestations, and provenance documents are themselves data with their own hashes, and usually contain hashes of the things they describe. The simplest form is a signature authenticating one object: Maven .asc files are detached signatures over individual artifacts, Debian’s Release.gpg and inline InRelease sign the repository metadata, and RPM carries signatures and digests inside the package header.
Metadata documents can also reference each other by hash to form their own chain, as TUF’s timestamp metadata authenticates the current snapshot metadata, which pins the version and optionally the hash of each targets metadata file, which in turn records hashes of the target artifacts, with root metadata separately distributing the trusted keys for each role; an OCI image manifest references its config JSON by digest alongside the layer digests, and go.sum records a hash of each module’s go.mod file separately from the module zip. Sigstore bundles, npm provenance, and PyPI PEP 740 attestations sit in both halves at once, being signatures over an artifact digest that are then themselves logged by hash in the Rekor transparency log.
The inputs to a build. A hash can be taken over a build recipe and everything it depends on before any output exists, so the identifier names the description of the work and is known ahead of the result. Nix input-addressed derivations compute the store path from a hash of the .drv file and the store paths of every input, and Guix works the same way.
Bazel and Buck compute an action digest from the command line, the environment, and the content hashes of every input file, and use it as the lookup key into a remote execution cache; Docker BuildKit keys its layer cache on each instruction plus the hashes of the files it can see; and ccache and sccache hash the compiler binary, flags, and preprocessed source to decide whether a translation unit needs recompiling. Nix’s experimental content-addressed derivations flip the relationship and derive the store path from a hash of the build output, which puts the hash itself back at the single-artifact level while keeping the input-keyed workflow around it.
Software Heritage applies content addressing across most of these levels at once for archived source code: a SWHID can name an individual file (swh:1:cnt:), a directory tree (swh:1:dir:), a revision (swh:1:rev:), a release (swh:1:rel:), or a snapshot of every reference in a repository (swh:1:snp:), each computed as a hash over the level below. The scheme is git-compatible by construction, so a git commit SHA-1 and the corresponding swh:1:rev: identifier encode the same digest.
Same contents, different hash
Hashing an archive as one opaque blob is simple but makes the result sensitive to bytes that have no effect on the code inside: gzip compression level and OS flag, tar member ordering, embedded mtimes, even the timezone of the machine that built it, so two archives with identical extracted contents can hash differently. A change to git archive’s gzip output in early 2023 broke every tool that had pinned a hash of a GitHub source tarball, including Bazel rules, Homebrew formulae, and Nix fixed-output fetchers, while consumers hashing the extracted tree were unaffected; GitHub reverted the change within days.
One answer is to skip the container and hash the file tree directly: Go’s dirhash computes SHA-256 of each file’s contents, sorts the resulting hash path lines, and records the hash of that listing as the h1: value in go.sum, so the zip’s encoding, compression, and per-file metadata never enter into it. A second h1: over just go.mod lets dependency resolution be verified without fetching the rest of the module.
The other answer is to define a canonical archive format and hash that: Nix’s NAR serialisation strips timestamps, normalises permissions, and sorts entries deterministically, so the same tree always produces the same archive bytes. Debian’s reproducible builds effort and the work on Python wheel reproducibility approach the same problem from the build side, making toolchains produce byte-identical output for identical source. Output-addressed schemes such as Nix’s CA derivations benefit most from getting this right, since identical inputs producing different output bytes means different store paths and no cache reuse across independent builds.
Which hash function is used, and whether the identifier records that choice, determines how hard it is to migrate later. Most of the schemes above encode the algorithm or a hash-scheme version in the identifier: SRI strings carry a sha384- or sha512- prefix, OCI digests are written sha256:…, go.sum lines begin with h1:, and IPFS CIDs embed a multihash code that self-describes both the function and the digest length. SWHIDs record a scheme version and object type in the prefix but currently fix the digest itself to git-compatible SHA-1.
Git originally hard-coded SHA-1 with no algorithm marker anywhere in the object format, and the transition to SHA-256, proposed in 2017, has taken nearly a decade of design and interop work; SHA-256 repositories can now be created, but SHA-1 remains the default and interoperability between the two object formats is still incomplete. npm’s older dist.shasum field is bare hex SHA-1 with the same limitation, which is why packuments now carry both it and an SRI-format dist.integrity field.
What the hash is used for
Verification. Comparing fetched bytes against a hash recorded earlier, either in a lockfile written at resolution time or in metadata served by the registry, so a tampered or corrupted download is rejected before it is unpacked. This is the most widespread use and the only one most lockfile formats implement, and because the check sits after the fetch it can be, and in several clients is, made optional or advisory.
Addressing. Using the hash as the key the client fetches by, so the request names the expected content and a wrong response is detectable on arrival without consulting anything else. Pulling an OCI image by digest and resolving an IPFS CID are both this. Unlike verification, addressing is hard to make optional, since a client that asked for a hash and accepted non-matching bytes would not have the object it requested.
Deduplication. Using the hash as a storage or transfer key so identical content is held or sent once. pnpm’s file store and OCI’s blob layer both share content across packages this way, the first within one machine and the second across every client of a registry.
Build caching. Treating a hash over the inputs to a computation as a cache key, so a build step whose inputs match a previous run can take that run’s output instead of repeating the work. Nix derivations and Bazel actions are the two implementations most package-adjacent tooling borrows from.
Tamper evidence. Placing hashes as nodes in a Merkle structure so that a small trusted root, such as a signed tree head or a TUF timestamp file, is enough to authenticate everything it transitively references. Go’s checksum database and Debian’s Release chain both let untrusted mirrors serve the full archive on the strength of one signature at the top.
Trust and transport
One consequence of addressing and tamper evidence taken together is that the question of where bytes come from can be separated from the question of whether to trust them: once a name has been bound to a hash by whatever authority you accept for that, the bytes can arrive from anywhere and be checked locally. Go’s module system is designed around this split, with GOPROXY free to point at any proxy or chain of proxies, public or private, while the checksum database at sum.golang.org independently confirms that what arrived matches what everyone else has seen for that module version.
Nix can pull from any binary cache added as a substituter, with each substituted path verified against a signature from a cache key the user has trusted, and fixed-output or content-addressed paths additionally checked against the content hash encoded in the path itself. Debian and its derivatives have run on third-party mirrors for over two decades on the strength of one signed Release file per suite chaining down to every .deb in the archive. An OCI pull pinned to a digest can be served by any registry mirror or pull-through cache, since the manifest and every layer are checked against their digests regardless of which host the bytes came from.
All of which assumes the binding from name to hash has already been established somewhere trusted, because content addressing does not decide which hash a name should point to in the first place. Which account is permitted to publish rails 8.0.0, and which artifact that version resolves to, is a governance question answered by the registry’s account system and the maintainers who hold the credentials, and a hash can only confirm that you received what they published.
Attacks on package supply chains tend to target that binding rather than the bytes themselves: account takeover and malicious maintainer handover publish hostile artifacts under an existing trusted name, typosquatting registers a name close enough to be mistaken for one, and dependency confusion arranges for a different registry to win resolution for the same name; in each case the hostile artifact’s hash is then recorded and propagated as faithfully as any other. In Zooko’s terms, a hash is secure and globally consistent and means nothing to a person, while a name is the part a person can actually use and is only as trustworthy as whoever controls it; binding one to the other is the job that registries, maintainer accounts, signatures, and transparency logs are there to do.