Cruft PM

cruft install is an npm-compatible package manager built into the runtime: it resolves registry versions the way npm does, writes a cruft-lock.json lockfile, hard-links packages from one content-addressed store, and verifies each download against its integrity hash. It runs no install lifecycle scripts. This page covers each piece and its supply-chain posture.

cruft install is a complete npm-compatible package manager living inside the runtime binary: registry resolution, a lockfile, a content-addressed store, and npm-v7-faithful peer-dependency handling. This page covers each piece and the design decisions behind them. It pairs with the module system, which consumes what this subsystem materializes.

The design goal: npm-faithful resolution

The governing decision is that Cruft does not innovate on resolution semantics. Pointed at the same registry with the same package.json, it aims to pick the same versions npm picks: full semver range support (caret, tilde, comparators, hyphen ranges, x-ranges, || unions), greatest-satisfying selection with a preference for the latest dist-tag when it fits, and the same transitive dedup, compatible ranges collapse to one shared version.

The reasoning: resolution semantics are ecosystem law. Packages are published, tested, and debugged against npm's picks; a "better" resolver that picks different versions is a compatibility bug factory. Cruft spends its design freedom elsewhere, on layout and integrity, where different choices don't change which code runs.

The store: one copy per version, ever

Where npm copies every package into every project, Cruft keeps a single global content-addressed store ($CRUFT_STORE, default ~/.cruft/store) and hard-links from it into each project's node_modules, the pnpm layout strategy.

Content addressing does two jobs:

  • Deduplication. Store entries are keyed by the package's integrity hash, so identical bytes are one entry regardless of how many projects (or even how many names/versions, if bytes coincide) reference them. A version you have installed anywhere is on disk once.
  • Corruption safety. An entry is marked complete only once fully written, so a crashed or partial download can never be mistaken for a valid package, the key is the content's hash, and a half-written entry never gets one.

The linked tree presents the same logical node_modules structure, so Node's resolution algorithm works unchanged. When you need a tree with no dependence on the shared store, copying a project to another machine, or editing package files in place, CRUFT_INSTALL_MODE=isolated writes self-contained copies instead.

The lockfile: frozen replay

Every install writes cruft-lock.json: the exact resolution, keyed by name@version, each entry carrying the resolved version, tarball URL, Subresource Integrity hash, its own dependencies, its placement in the tree, and provenance for anything auto-installed (see peers below). Commit it.

The interesting mechanism is the frozen-replay decision. On each install, Cruft asks one question: does the lockfile cover everything package.json declares?

  • Covered → frozen mode: zero network resolution. The closure is read straight from the lockfile and materialized from the store. Delete node_modules and reinstall, and you get the byte-identical tree without touching the registry.
  • Not covered (you added a dependency) → full resolution runs for the new closure, and the lockfile is rewritten to include it.

There is no separate --frozen-lockfile flag to remember: freezing is the automatic consequence of the lockfile being sufficient, and re-resolution the automatic consequence of it not being. The reproducibility guarantee follows: two installs from the same committed lockfile produce the same node_modules, same packages, versions, and layout, because the lockfile is the single source of truth and the store is keyed by content integrity.

Peer dependencies: the npm-v7 posture

Peers are the hardest part of npm semantics, and Cruft implements the modern posture exactly:

  • Auto-install. After the ordinary dependency resolution completes, a peer fixpoint runs: any peer that a resolved package declares, that nothing in the tree provides, and that isn't marked optional (peerDependenciesMeta.<name>.optional) is resolved and installed, hoisted to the top level as the single shared copy that peer semantics demand. The lockfile records why it's there (auto-peer provenance), so a later reader can distinguish "you asked for this" from "react-dom needed this."
  • Conflicts hard-fail. When two packages demand incompatible versions of the same peer, there is no correct single copy. Cruft refuses the install and exits non-zero, npm's ERESOLVE posture, rather than silently picking a version half the tree disagrees with.
  • Escape hatches, explicitly opt-in. CRUFT_PM_LEGACY_PEER_DEPS=1 (npm's --legacy-peer-deps) proceeds by picking the version satisfying the most demands and warning about the rest; CRUFT_PM_NO_PEERS=1 ignores peers entirely. Both are named as escape hatches because both can produce trees that fail at runtime.

Configuration

Everything is environment variables; there is no config file today:

VariableDefaultControls
CRUFT_REGISTRYregistry.npmjs.orgRegistry to resolve/download from
CRUFT_STORE~/.cruft/storeStore root
CRUFT_INSTALL_MODElinkedlinked (store hard-links) vs isolated (copies)
CRUFT_PM_LEGACY_PEER_DEPSoffWarn-and-pick on peer conflict
CRUFT_PM_NO_PEERSoffIgnore peers entirely
CRUFT_PM_CONCURRENCY16Concurrent registry connections

The active registry and mode are echoed on the first line of every install, so a log always shows what configuration produced it.

Boundaries

Deliberate differences from npm: the linked store layout (same logical tree, different physical location), and cruft-lock.json rather than package-lock.json (the two are not interchangeable; Cruft neither reads nor writes npm's lockfile).

Not yet implemented, boundaries, not decisions: no add/remove/init/ update (edit package.json, re-run install); only dependencies of the root project are read (devDependencies etc. are not, though peers of installed packages are fully handled); no lifecycle scripts (postinstall does not run); no workspaces.

Skipping lifecycle scripts is currently listed as unfinished, but it interacts with Cruft's security story: postinstall scripts are the npm ecosystem's largest supply-chain attack surface, and a capability-secured runtime may well choose gated or sealed script execution rather than npm parity here. Watch this boundary rather than assuming it converges to npm.

Integrity enforcement

The bytes resolved are the bytes that execute, enforced by an integrity gate wired into the fetcher. The question that matters for a supply-chain control is whether verification is enforced or merely advisory. It is enforced, with two caveats.

What the gate does:

  • Verification is unconditional and runs before extraction. Every downloaded tarball is hashed against its expected integrity, and the check happens before a single byte is written, so a mismatch means nothing lands in the store or node_modules.
  • A mismatch aborts the whole install. The error propagates to a non-zero install failure rather than being logged and ignored.
  • A package with no integrity at all is rejected, not silently trusted. There is no trust-on-first-use path: a dependency that carries neither an SRI integrity nor a legacy shasum never installs.
  • Frozen replays re-verify. A frozen install re-hashes the downloaded bytes against the integrity pinned in the lockfile, so the lockfile is a binding commitment, not a hint.
  • Malformed or unknown-algorithm integrity strings fail closed (they error, they do not skip the check), and the hash primitives do not panic on hostile bytes.

The two caveats:

  • SHA-1 downgrade when only shasum is present. When a package advertises an SRI sha512- integrity, that is what is verified. But if the strong field is absent and only the legacy SHA-1 shasum is present, the install falls back to SHA-1, which is collision-broken. An attacker who can strip the strong field from registry metadata forces the weak path, and the transport does not currently prevent that (see the TLS caveats in TLS and cryptography). The strong hash should not be strippable down to the weak one for registry installs.
  • The gate has no end-to-end regression test. Unit tests cover rejection of a mismatch at the function boundary, but no running test drives the full download, verify, abort chain. So the gate is one careless edit away from silently becoming a no-op without the suite noticing, which for a supply-chain control is itself a risk worth naming.

Two smaller notes: npm's multi-hash SRI (several digests in one string, verify the strongest) is not supported and such a string fails closed rather than downgrading, and the hash comparison is not constant-time, which is low-risk for a public integrity value. In short: the integrity gate is enforced and fail-closed today, and its remaining risk is downgrade-strippability and the absence of a test that would keep it from regressing.

The wider supply-chain surface

The integrity gate is one part of the story. Looked at from the angle of a hostile registry, the rest of the install path shows one strong posture, one open exposure, and a robustness gap.

The strongest thing first: cruft install runs no lifecycle scripts. There is no process spawn anywhere in the package manager; preinstall, install, postinstall, and prepare are read and reported as skipped, never executed, from the root or from any dependency. Arbitrary code from a dependency running at install time is the single largest supply-chain vector in the npm ecosystem, and Cruft closes it by omission. The store is also content-addressed and a crafted package name cannot escape the store directory.

The open exposure: the tarball URL is trusted. The resolver takes each package's dist.tarball URL verbatim from the registry metadata and fetches it, following cross-host https redirects, with no check that the URL is on the registry host. A package can therefore point its own tarball at an attacker-controlled host (the classic tarball-substitution attack). The integrity check does not save you here, because the expected hash comes from the same metadata object as the URL: an attacker who controls the packument controls both. This is why the integrity gate is necessary but not sufficient: it proves the bytes match what the metadata claimed, not that the metadata came from the real registry.

Lockfile trust: a frozen install re-verifies each tarball against the integrity recorded in cruft-lock.json, but the lockfile itself is parsed and trusted with no authenticity check, so a tampered lockfile (an attacker URL plus a matching hash) passes frozen verification unchallenged.

Robustness: hostile registry metadata can crash the installer (a crafted numeric prerelease version overflows an integer and aborts), and there is no tarball-size cap or dependency-tree bound and no adversarial-registry test suite, so the install path is effectively untested against a malicious registry.

In short: Cruft's installer makes the best supply-chain decision available (no lifecycle scripts) and verifies content integrity, but it still trusts the registry to tell it where to fetch from and trusts the lockfile it reads, so it is not yet safe against a compromised or malicious registry.

Why in the runtime binary?

The one-binary choice is architectural, not packaging convenience:

  • The runtime's module loader consumes the actual package graph the installer built, the --sealed-deps boundary and per-module capability attribution come from installation truth, not path heuristics.
  • The store's integrity hashes carry from download through lockfile through load: the bytes that were resolved are the bytes that execute.
  • Install semantics and runtime resolution semantics can't drift apart, since they ship and are tested together, with the full install-then-run path exercised under both Cruft and a reference runtime.