Files
pnpm/cspell.json
Victor Sumner 61810aa684 feat: add --frozen-store for installs against a read-only store (#12190)
## What

Adds an opt-in `frozenStore` / `--frozen-store` setting (default `false`) that lets `pnpm install --offline --frozen-lockfile` run against a package store that lives on a **read-only filesystem** — a Nix store, a read-only bind mount, an OCI layer.

## Why

A normal install fails against such a store **not** because it writes package content, but because it unconditionally:

1. opens the SQLite `index.db` in WAL mode, which needs to create `-shm`/`-wal` sidecars in the store directory; and
2. writes a project-registry entry under the store.

Both fail with `attempt to write a readonly database` / `EROFS` on a read-only store directory, even when the store is complete and the lockfile is frozen. This blocks any deployment that wants an immutable, content-addressed store.

## How

When `frozenStore` is enabled, pnpm opens `index.db` through the SQLite **`immutable=1`** URI — which tells SQLite the file cannot change underneath it, so it bypasses the WAL/`-shm` sidecar machinery entirely and reads the raw file with zero sidecar creation — and suppresses every store-write path. Pair it with `--offline --frozen-lockfile` against a fully-populated store. It is incompatible with two settings that would write into the store, and each throws a clear config-conflict error before any network or store access: **`--force`** (which bypasses the no-write-on-hit skip → `ERR_PNPM_CONFIG_CONFLICT_FROZEN_STORE_WITH_FORCE`) and a configured **pnpr server** (which would fetch and write packages into the store → `ERR_PNPM_FROZEN_STORE_INCOMPATIBLE_WITH_PNPR`).

> Plain `SQLITE_OPEN_READ_ONLY` is **not** sufficient: opening a WAL-mode db read-only still tries to create the `-shm` sidecar, which fails on a read-only *directory*. `immutable=1` is the load-bearing piece.

> **Node.js requirement:** `node:sqlite` only passes `SQLITE_OPEN_URI` to SQLite (so the `immutable=1` query is honored rather than treated as part of a literal filename) starting in **v22.15.0** (22.x line), **v23.11.0**, and every **v24+**. pnpm's `engines` floor is `>=22.13`, so on a runtime older than that the frozen open is detected up front and fails with a clear `ERR_PNPM_FROZEN_STORE_UNSUPPORTED_NODE` instead of SQLite's cryptic "unable to open database file". (pacquet uses rusqlite with an explicit `SQLITE_OPEN_URI` flag, so it has no such floor.)

> The `immutable=1` URI path is also percent-encoded (`%`→`%25`, `?`→`%3f`, `#`→`%23`, in that order, leaving `/` literal) so a store path containing those characters doesn't truncate the path or inject a spurious query parameter — applied identically in both stacks.

### Build backstop under the global virtual store

Under the global virtual store (default), a package's directory lives **inside** the store (`{storeDir}/links/...`). Applying a patch or running an allowlisted lifecycle script writes into that directory — so on a frozen store it would crash mid-build with a raw `EROFS`. A fully-seeded store never reaches the build step (patched/built packages are imported from the side-effects cache and filtered out by the `isBuilt` gate), so any residual build candidate means the seed is **missing that package's build output**.

`buildModules` now refuses up front with `ERR_PNPM_FROZEN_STORE_NEEDS_BUILD` and actionable guidance ("rebuild the seed with their scripts enabled, or remove them from `onlyBuiltDependencies`") instead of failing cryptically once a script starts. The check is gated on the global virtual store — under the isolated linker, slot directories live in the writable project-local store, so builds there are fine. Non-allowlisted scripts never run, so they are not treated as a blocking write.

Bin-linking has its own read-only-store edge under the global virtual store. On a **warm** checkout (the project's `.bin/<name>` already points at the seed target) `linkBin` returns before touching the store, so it is write-free. But on a **fresh** checkout it (re)creates the bin and calls `fixBin`, whose `chmod` targets the bin's **source file inside the store** (`{storeDir}/links/...`) — which is refused with `EPERM`/`EACCES` on a read-only store, even though a complete seed already ships that bin executable (so the `chmod` is redundant). `@pnpm/bins.linker` now wraps that call in `ensureExecutable`: it swallows the refusal when the target is already executable and rethrows otherwise, so bin-linking is write-free against a frozen store on a cold checkout too, while a genuinely non-executable bin (a broken seed) still surfaces as an error.

The blocking predicate distinguishes the two write kinds: a **patch** is applied regardless of `ignoreScripts`, so a patched package is always blocked; a **lifecycle script** is suppressed under `ignoreScripts`, so an allowlisted build-requiring package is *not* blocked when scripts are off (it would write nothing). This avoids falsely rejecting a valid `--ignore-scripts` frozen install. **Optional dependencies are exempt**: a build or patch failure on an optional dependency is non-fatal at runtime, so a seed missing an optional package's build output skips that build (emitting the `skipped-optional-dependency` log) instead of blocking the install — in both stacks.

### Both stacks (parity rule)

**TypeScript pnpm CLI**
- Config plumbing (`@pnpm/config.reader`): `frozen-store` type, config-file key, default, `Config.frozenStore`.
- The read-only open branch (`@pnpm/store.index`): `immutable=1`, read-only statements, throwing mutators.
- Wiring through `@pnpm/store.controller` and `@pnpm/store.connection-manager` to the sole `StoreIndex` construction site.
- Gating the project-registry write (`@pnpm/installing.context`).
- The `--force` / pnpr-server conflict guards (`@pnpm/installing.deps-installer`) and CLI surface (`@pnpm/installing.commands`).
- **After-install rebuild** (`@pnpm/building.after-install`): the post-install rebuild opens its `StoreIndex` immutably under the flag, so re-reading the store for a rebuild never attempts a writable open against the frozen store.
- **Worker fix:** `@pnpm/worker` opens its *own* writable `StoreIndex` on every `readPkgFromCafs` cache hit, so a pure read crashed on a frozen store. `frozenStore` is threaded through to `getStoreIndex` and keyed into its connection cache.
- **Build backstop** (`@pnpm/building.during-install`): `buildModules` throws `ERR_PNPM_FROZEN_STORE_NEEDS_BUILD` for a GVS slot that would build/patch on a frozen store, honoring `ignoreScripts`; threaded from `@pnpm/installing.deps-installer`.
- **Bin-linking on a read-only store** (`@pnpm/bins.linker`): `linkBin` wraps the `fixBin` chmod in `ensureExecutable`, which tolerates `EPERM`/`EACCES` when the bin's store-resident source is already executable (a complete seed) and rethrows otherwise — so a fresh checkout against a frozen store links bins without crashing on the redundant chmod. Catch-on-failure keeps the writable hot path at zero added syscalls.

**Rust pacquet**
- A dedicated `open_immutable` / `shared_immutable_in` opens via `immutable=1`, selected only under the flag. Plain `open_readonly` keeps the ordinary `SQLITE_OPEN_READ_ONLY` open (WAL locking intact) because normal installs read the index while the same process's `StoreIndexWriter` writes it concurrently — an immutable connection skips all locking and change detection, so a concurrent writer would make those reads undefined.
- `--frozen-store` CLI flag + `frozenStore` workspace-yaml setting.
- The store-index writer is replaced with a drain-and-drop stub (`spawn_disabled`) and `init_store_dir_best_effort` is skipped under the flag.
- **Build backstop:** `build_modules` returns `BuildModulesError::FrozenStoreNeedsBuild` (`ERR_PNPM_FROZEN_STORE_NEEDS_BUILD`) under the same GVS + frozen-store condition, threaded from `config.frozen_store`. The gate keys off `should_run_scripts` (which already folds the allow-build policy), so it is correct without an explicit ignore-scripts branch — pacquet has no configurable ignore-scripts mode yet.

pacquet already separated read-only index access (`shared_readonly_in`, or `shared_immutable_in` under the flag) from writes, so it never had the worker-conflation bug; the flag makes the "no writes attempted" contract explicit and gates the remaining best-effort write attempts.

## Testing

- **TS:** `store.index` frozen-mode-on-`0555`-directory test (reads work, writes throw `ERR_PNPM_FROZEN_STORE_WRITE`) plus a path-with-`?` open test — both gated on the runtime's immutable-URI support, with a complementary test asserting `ERR_PNPM_FROZEN_STORE_UNSUPPORTED_NODE` fires where that support is absent (the CI Node 22.13.0 path); `config.reader` round-trip; `deps-installer` `--force` and pnpr-server conflict guards; `worker`/`package-requester` unit tests. End-to-end on a `chmod -R 0555` store: install succeeds, `node_modules` materializes, no `-shm`/`-wal`/`-journal` sidecars; negative control without the flag fails as expected; incomplete store → clean offline error.
- **pacquet:** `open_immutable_reads_wal_db_on_readonly_directory` unit test plus the `immutable_sqlite_uri` encoding test and a path-with-`?` open test; yaml + CLI fold tests; **integration test** `frozen_store_installs_against_a_read_only_store` — primes a store, `chmod 0555` the tree, runs `install --frozen-lockfile --frozen-store --offline`, asserts success + materialized `node_modules` + zero sidecars. Confirmed load-bearing by reverting the `immutable=1` fix (test then fails).
- **Build backstop (both stacks):** `building/during-install` unit tests — approved-build-not-cached and patched-not-cached refuse; cached, non-allowlisted, and non-GVS cases pass through; **approved-build-under-`ignoreScripts` passes through while patched-under-`ignoreScripts` still refuses** — and the matching pacquet `build_modules` tests (`frozen_store_gvs_patch_not_seeded_refuses` + GVS-off / frozen-off controls). Each confirmed load-bearing by disabling the relevant guard and watching the corresponding test fail.
- **Bin-linking (`bins/linker`):** `ensureExecutable` tests with `fixBin` mocked to reject with `EPERM` — an already-executable bin source resolves (and `fixBin` is asserted called, so it isn't the warm skip-guard passing), a non-executable one rethrows `EPERM`. Confirmed end-to-end by running the built `linkBins` against a real `chflags uchg`-immutable store: the executable-seed case resolves and links the bin, the non-executable-seed control throws `EPERM`.

A changeset is included with `"pnpm": minor` and `"@pnpm/bins.linker": patch` (the read-only-store bin-linking fix).
2026-06-12 08:36:08 +02:00

423 lines
6.5 KiB
JSON

{
"ignorePaths": [
"**/nodeReleaseKeys.ts",
"**/nodeReleaseKeys.d.ts",
"**/node_release_keys.rs"
],
"words": [
"adduser",
"adipiscing",
"agentkeepalive",
"agentkeepalive's",
"amet",
"andreineculau",
"appdata",
"applyq",
"archy",
"argumentless",
"armv",
"autocheckpoint",
"autocompleting",
"autofix",
"autofixed",
"autoinstalled",
"autozoom",
"babek",
"Backblaze",
"badheaders",
"baires",
"behaviour",
"blabla",
"Bluesky",
"brasileiro",
"bryntum",
"buildx",
"cafile",
"cafs",
"camelcase",
"canonicalizer",
"cantopen",
"canva",
"cerbos",
"certfile",
"chmods",
"clonedeep",
"cmds",
"Codeberg",
"codeload",
"codenames",
"codesign",
"colorterm",
"comver",
"copyfiles",
"corejs",
"corepack",
"corge",
"cowsay",
"Creds",
"cryptiles",
"cves",
"cwsay",
"cyclonedx",
"deburr",
"dedup",
"denoland",
"denolib",
"deptype",
"desugared",
"desugars",
"devextreme",
"devowl",
"dgimuvys",
"didyoumean",
"dirtyforms",
"diskusage",
"dislink",
"dpkg",
"drivelist",
"duplexify",
"eacces",
"eagain",
"ebadplatform",
"ebusy",
"eexist",
"ehrkoext",
"eintegrity",
"eisdir",
"elifecycle",
"elit",
"embedder",
"emfile",
"enametoolong",
"endregion",
"eneedauth",
"enoent",
"enotempty",
"enten",
"eotp",
"eperm",
"epipe",
"erofs",
"errcode",
"esac",
"etamponi",
"exdev",
"execa",
"exploitability",
"fakehash",
"fellback",
"fetchings",
"filenamify",
"filesystem",
"filesystems",
"fnumber",
"foobarqar",
"foofoo",
"footgun",
"forgejo",
"fsevents",
"gabor",
"garply",
"gcttmf",
"getattr",
"ghes",
"ghsa",
"ghsas",
"gitea",
"globalconfig",
"globstar",
"gnueabihf",
"gpgsign",
"grault",
"gruntfile",
"gwhitney",
"haptics",
"hardlink",
"hardlinked",
"hardlinking",
"hardlinks",
"hashbang",
"highmaps",
"hikljmi",
"hoistable",
"homepath",
"hosters",
"htpasswd",
"hyperdrive",
"idempotency",
"imagetools",
"imurmurhash",
"invalidformat",
"ionicons",
"isexe",
"istvan",
"italiano",
"jega",
"jhcg",
"jnbpamcxayl",
"junyi",
"kebabcase",
"kevva",
"keyfile",
"keyid",
"keytype",
"killcb",
"kochan",
"koorchik",
"ldid",
"ldni",
"leniolabs",
"libc",
"libnpmpublish",
"libnpx",
"libsql",
"libzip",
"licence",
"licences",
"lifecycles",
"linuxstatic",
"localappdata",
"lockfiles",
"loglevel",
"logstream",
"longlink",
"longpaths",
"loong",
"luca",
"martensson",
"maxtimeout",
"mdast",
"metafile",
"millis",
"minioadmin",
"mintimeout",
"mmap",
"monorepolint",
"montudor",
"moonrepo",
"mountpoint",
"msgpack",
"msgpackr",
"msvc",
"msys",
"musleabihf",
"mycomp",
"mycompany",
"myorg",
"mypackage",
"mytoken",
"ndjson",
"nerfed",
"newversion",
"nistp",
"NOASSERTION",
"nodetouch",
"noent",
"nonexec",
"noninjected",
"nonvulnerable",
"nopadding",
"noproxy",
"nosystem",
"nothrow",
"npmcli",
"npmignore",
"npmjs",
"npmx",
"ntfs",
"nushell",
"ofjergrg",
"onclickoutside",
"oomol",
"openharmony",
"openpgp",
"ossl",
"outfile",
"overrider",
"packlist",
"packr",
"packument",
"packuments",
"pacquet",
"paralleljs",
"parallelly",
"parseable",
"partialmatch",
"pathext",
"pegjs",
"pidtree",
"pify",
"pkgname",
"pkgs",
"plotly",
"plugh",
"pnpmfile",
"pnpmfiles",
"pnpmjs",
"pnpmrc",
"pnpmtest",
"pnpr",
"polyfilling",
"português",
"posix",
"postbuild",
"postfoo",
"postpack",
"postprepare",
"postpublish",
"postrestart",
"postshrinkwrap",
"poststart",
"poststop",
"posttest",
"postuninstall",
"postversion",
"preact",
"prebuild",
"prefoo",
"prefs",
"preinstall",
"premajor",
"preminor",
"prepatch",
"prepublish",
"prereleases",
"prerestart",
"preshrinkwrap",
"prestart",
"prestop",
"preuninstall",
"preversion",
"prioritizer",
"promisified",
"proxied",
"pwsh",
"qrcode",
"quux",
"rcompare",
"redownload",
"refclone",
"refetched",
"reflattened",
"reflink",
"reflinked",
"reflinks",
"rehoist",
"reimagining",
"reka",
"Rekor",
"relinks",
"renderable",
"replit",
"reqheaders",
"rescopable",
"rescope",
"rescoped",
"rescopes",
"rescoping",
"rimrafed",
"riscv",
"rmgr",
"rpmdevtools",
"rpmlint",
"rstacruz",
"rushstack",
"rustup",
"safecrlf",
"scopeless",
"sdiff",
"searchexclude",
"searchlimit",
"searchopts",
"searchstaleness",
"sels",
"semistrict",
"serp",
"serverjs",
"shasums",
"sheetjs",
"shlex",
"sigstore",
"sindresorhus",
"sirv",
"SLSA",
"soporan",
"sopts",
"spdxdocs",
"SPDXID",
"sqld",
"srcset",
"ssri",
"stackblitz",
"stacktracey",
"stdtype",
"streamsearch",
"stringifying",
"subcmd",
"subdep",
"subdependencies",
"subdependency",
"subdeps",
"subdir",
"subdirs",
"subkey",
"subkeys",
"subpkg",
"subresource",
"supercede",
"Swatinem",
"syml",
"syncer",
"syscall",
"syscalls",
"szia",
"tabtab",
"taffydb",
"taiki",
"tarballtemplate",
"teambit",
"tempy",
"testcase",
"tlog",
"TLSV",
"toctou",
"todomvc",
"toplevel",
"TOTP",
"tsgo",
"tsparticles",
"turso",
"typecheck",
"unallowed",
"undeprecate",
"underperformance",
"undollar",
"unextractable",
"uninstallation",
"unnest",
"unparseable",
"unreviewed",
"unskip",
"unstar",
"usecase",
"userconfig",
"userprofile",
"ustar",
"uuidv",
"valign",
"vuln",
"webauth",
"webcontainer",
"winst",
"workleap",
"worktree",
"worktrees",
"wrappy",
"xmarw",
"yazl",
"zkochan",
"zoli",
"zoltan"
]
}