Skip to content
ocx
install

Storage

This page explains how OCX lays out its data directory as independent stores, and why each store exists.

Most package managers keep everything in a single mutable tree. Installing a new version silently replaces the old one, breaking anything that referenced the old path. They also hit the network on every operation, making offline use and reproducible CI awkward.

OCX takes the opposite approach. The data directory under ~/.ocx/ (configurable via OCX_HOME) is split into independent stores. Each owns one concern, each is content-addressed where it matters, and each can be reasoned about in isolation.

The user-facing surface lives in the Storage section of the user guide. It covers what ocx package install produces, where to find binaries, and which paths are safe to embed.

Content-addressed storage, a local index, persistent state, stable symlinks, and download staging each get their own store under $OCX_HOME:

  • ~/.ocx/
    • packages/immutable, content-addressed assembled packages
    • layers/extracted OCI layers, shared across packages that reference the same layer
    • blobs/raw OCI blobs: manifests, image indexes, referrers
    • index/local index collection, one subtree per source (see Index below)
    • toolchain/the global tier’s rendered toolchain tree, described on the Toolchain Tree page
    • projects/the GC project ledger read by ocx clean: one symlink per registered project, pointing at its directory. Distinct from state/projects/<key>/
    • state/persistent runtime state, one subdirectory per concern
      • projects/one subdirectory per project key: shell-activation consent and the render stamp
      • update-check/throttle markers for the background update-check probe
      • managed-config/the managed-config tier’s own snapshot pin
      • patch-descriptors/per-repository record of whether a patch descriptor was found, re-checked by install, pull, lock, add, update and package test
      • patch-companions/per-repository companion tag→digest pins, never in the local index
      • host/the host-capability record, capabilities.json
      • referrers/per-registry cache of whether the registry supports the referrers API
      • trust_root/per-Rekor-host trust-root cache for offline verification
    • symlinks/stable symlinks safe to embed in shell profiles and configs
    • temp/download staging, cleaned on successful install
    • shims/generated launcher directories for lazy-mode packages, described under Deferred packages
    • .bin/ocx-shim, the shared shim executable blob
    • locks/cross-process lock files, content-keyed and safe to delete when no ocx process is running. Also holds the project mutation mutex for every ocx.toml this machine has written to, scoped under project-mutate/

Each store has a single responsibility. Upgrading a package updates symlinks in symlinks/ and adds an entry to packages/, but never touches index/. The stores can be understood one at a time. The global tier’s toolchain/ directory is the rendered view of a toolchain, described on Toolchain Tree.

Path component encoding

Registry names, repository names, and tags that appear as directory or file names are Characters outside [a-zA-Z0-9._-] are replaced with underscores. For example, localhost:5000 becomes localhost_5000. This prevents the POSIX PATH separator (:) from corrupting environment variables that embed these paths. before they become filesystem path components. Dots and hyphens are preserved so domain names (ghcr.io) and semantic versions (3.28.1) stay readable on disk.

Digest-addressed directories use a two-level shard (sha256/{2hex}/{30hex}/). Only 32 hex characters are encoded in the path. The full digest is written to a sibling digest file inside each entry and is the source of truth for identity.

When OCX installs a package, the actual files land in packages/. The critical design decision: the path comes from the content’s A 64-character hex fingerprint of the exact bytes in a file or archive. The same bytes always produce the same digest., not from the package name or tag.

  • ~/.ocx/packages/
    • {registry}/
      • sha256/ab/c123…/one directory per unique package build
        • content/package files: binaries, libraries, headers
        • entrypoints/generated launchers for declared entry points, one script per name
        • metadata.jsondeclared env vars and extraction options
        • refs/back-references to install symlinks, which guard against deletion

This A storage scheme where every item’s address is derived from a cryptographic hash of its contents. The same bytes always map to the same address, so accidental duplication is impossible and silent corruption is detectable. has two consequences:

  • Automatic deduplication. If kitware/cmake:3.28 and kitware/cmake:latest resolve to the same binary build, they share one directory on disk. Storage scales with the number of distinct builds, not the number of tags that reference them.
  • Immutability. A path under sha256/<shard>/ never changes its contents. It is safe to reference directly from scripts or cache layers that need a known, stable binary.

When a package’s metadata.json declares a non-empty entrypoints object, OCX materializes a sibling entrypoints/ directory at install time. It holds a launcher for each entry: a POSIX .sh launcher for Unix shells and, on Windows, a native <name>.exe shim with a one-line <name>.shim sidecar.

Each launcher carries the digest-addressed package root and re-enters via ocx launcher exec. Every invocation therefore runs under the same clean-environment guarantee as ocx package exec <package>.

Packages that declare no entrypoints never get an entrypoints/ directory. See the entry points guide for the publisher workflow.

The refs/symlinks/ subdirectory inside each package tracks every install symlink that points to it. That directory is the GC root signal. ocx clean starts a reachability walk from every package with a live refs/symlinks/ entry and follows forward-refs through all three tiers.

How back-references work

When ocx package install kitware/cmake:3.28 creates the symlink symlinks/…/kitware/cmake/candidates/3.28 → packages/…/sha256/ab/c123…/, it simultaneously writes a back-reference entry inside the package’s refs/symlinks/ directory. The link targets the package root, not content/, because consumers traverse <symlink>/content themselves. Removing the symlink via ocx package uninstall removes that back-reference entry.

ocx clean then builds a reachability graph across all three tiers. Packages with live refs/symlinks/ entries (and any profile content-mode references) are roots. A single BFS pass follows each package’s forward-refs in refs/deps/, refs/layers/, and refs/blobs/. Packages, layers, and blobs that remain unreachable across all three tiers are deleted in one sweep.

A dependency is protected by the liveness of its dependents, not by a back-reference inside itself. OCX records each dependency as a forward-ref inside the dependent package’s refs/deps/ directory, pointing at the dependency’s content/. Nothing is written into the dependency’s own refs/symlinks/. The same ocx clean sweep that removes the last dependent therefore also collects the unreachable dependency.

A package on disk looks monolithic, with one content/ directory under one digest. The bytes inside can still come from more than one upstream archive. Each archive is a An OCI image layer: a single tar archive (gzip or xz compressed) addressed in the registry by its own SHA-256 digest. On pull, OCX extracts each layer once into the shared layer store and assembles the package’s content/ directory from them via hardlinks. It is stored once in ~/.ocx/layers/ and shared across every package that references it.

  • ~/.ocx/layers/
    • {registry}/
      • sha256/ab/c123…/one directory per unique layer blob
        • content/extracted layer files, the hardlink source for assembled packages

The layer store enables a different kind of dedup than the package store. The package store dedups whole builds: two tags pointing at the exact same binary share one directory. The layer store dedups parts of builds. A 200 MB shared base layer used by ten packages is downloaded, extracted, and stored exactly once.

Each package’s content/ is then assembled by hardlinking files from one or more layer directories. The package looks complete even though no bytes were copied.

When you call ocx package push, every positional argument after the identifier is a layer, and there can be zero or more. Each layer is either a path to a local archive file or a digest reference to a layer that already exists in the target registry. A push with zero layers is valid too. It produces a config-only OCI artifact, useful for referrer-only or description-only manifests. It requires --metadata, because there is no file layer to sniff a sibling metadata path from.

Terminal window
# Two-layer package: shared base + package-specific top
ocx package push -p linux/amd64 acme/mytool:1.2.3 base.tar.gz tool.tar.gz
# Re-publish with the base layer reused by digest — no re-upload.
# The digest ref must spell out the original archive extension
# (`.tar.gz` / `.tgz` / `.tar.xz` / `.txz`) — OCI blob HEADs do not
# carry the media type, so OCX refuses to guess.
ocx package push -p linux/amd64 acme/mytool:1.2.4 sha256:<hex>.tar.gz newtool.tar.gz

The order matters for the manifest descriptor list, but assembled content must not overlap. Two layers cannot contain the same file path. Overlap is rejected at install time with a clear error.

When you run ocx package install kitware/cmake:3.28, how does OCX know which binary to fetch? It looks up the tag 3.28 in the local index and finds the corresponding The content fingerprint stored in the OCI registry manifest. A tag like 3.28 is a human-readable alias for the digest of the exact binary build behind it.. Unlike the other stores on this page, the index’s home is not fixed to $OCX_HOME. --index / OCX_INDEX can point it at an entirely different collection, and it defaults to index/ when neither is set.

  • ~/.ocx/index/
    • ocx.sh/one subtree per source, the local index collection
      • c/index.jsoncatalog: ns/pkg → sha256(root)
      • p/{ns}/{pkg}.jsonroot document: repository pointer, tags, publisher status
      • p/{ns}/{pkg}/o/sha256/{hex}.jsondispatch object: the filename is the object’s own digest, never a leaf manifest

The index is a collection. Each source’s subtree reflects the state of that remote registry (or the public index) at the last time you refreshed it. It is dispatch-only: a leaf platform manifest is never copied into the index. The index holds only the tag → digest pointer and the platform → digest dispatch object it names, each verified against its own digest. A package’s version choice therefore resolves offline from the index alone, with no other store or network access required.

When the tag names a manifest directly (a single-platform package), OCX checks the package store before the registry. An already-installed package’s leaf manifest was cached there at install time, so re-resolving it needs no network. Only a genuinely cold digest reaches the registry. The full design lives in Indices, Local index and Public index.

The index is never walked by ocx clean. See Garbage Collection above.

Two distinct platform keys pointing at the same manifest digest is legitimate, and OCX never rejects it. The canonical case is Rosetta 2. A publisher who ships only a darwin/amd64 build can also list that manifest under darwin/arm64. Apple Silicon hosts then install it and run it through x86-64 translation instead of finding no arm64 build.

The two keys differ. One names the binary’s native architecture and the other a host that can run it by translation, but both may point at the same bytes. OCI Image Index entries are content-addressed. The digest identifies the bytes, and neither the spec nor OCX requires a one-to-one mapping between platform descriptors and content. A generic OCI client that deduplicates identical layers across platform entries relies on the same property.

Package paths embed the digest: ~/.ocx/packages/ocx.sh/sha256/ab/c123…/. That path changes on every upgrade. You cannot put it in a shell profile, an IDE config, or a build file and expect it to still work next month.

symlinks/ solves this with A symbolic link (symlink) is a filesystem entry that points to another path. The link’s own path never changes. Only its target can be updated. Tools that reference the link path continue to work transparently after the target is re-pointed to a new version. whose paths never change. Only their targets are re-pointed when you install or select a new version.

  • ~/.ocx/symlinks/
    • {registry}/
      • {namespace}/a repository is two segments, so it is two directories: kitware/ then cmake/
        • {package}/
          • currentactive package root, set by ocx package select
            • content/
            • entrypoints/
            • metadata.json
          • candidates/
            • 3.28pinned package root, created by ocx package install kitware/cmake:3.28
            • 3.30pinned package root, created by ocx package install kitware/cmake:3.30

Two symlink entries cover every use case. Both target the package root (packages/{registry}/{algorithm}/{2hex}/{30hex}/) rather than the content/ subdirectory. Consumers traverse into …/content/ for files and …/entrypoints/ for launcher scripts, or read …/metadata.json directly.

candidates/{tag}: pinned to a specific version. Created by ocx package install and pointed at the exact digest that tag resolved to at install time. Several versions can coexist. The candidates for cmake 3.28 and 3.30 both remain until you explicitly uninstall one. Even if the registry re-pushes the 3.28 tag with a different binary, your candidate still points to the build you installed.

This slot is host-only. Installing a foreign platform (-p windows/amd64 on a non-Windows host, or --select of one) populates the object store but leaves both candidates/{tag} and current untouched. A platformless resolver therefore never hands you a package the host cannot run. Reference a foreign-platform install by its digest instead.

current: a floating pointer to whichever candidate you last declared active. Set by ocx package select (or ocx package install --select in one step). It is never updated automatically, not when you install a newer version and not when you refresh the local index. This is intentional: tools referencing current should only change behavior when you decide they should.

When the selected package declares entrypoints, the composed environment emits a PATH export for {repo}/current/entrypoints. The environment comes from ocx package env or from the global toolchain activation eval "$(ocx --global env --shell=sh)". Every declared launcher becomes a top-level command. See Entry Points for how launchers, PATH, and clean-env execution compose.