@pnpm/installing.deps-installer 1101.1.2 → 1101.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,225 @@
1
+ import { lockfileVerificationLogger } from '@pnpm/core-loggers';
2
+ import { hashObject } from '@pnpm/crypto.object-hasher';
3
+ import { PnpmError } from '@pnpm/error';
4
+ import { nameVerFromPkgSnapshot } from '@pnpm/lockfile.utils';
5
+ import pLimit from 'p-limit';
6
+ import { recordVerification, tryLockfileVerificationCache, } from './verifyLockfileResolutionsCache.js';
7
+ // Cap the per-entry breakdown so a verifier rejecting hundreds of entries
8
+ // (e.g. a poisoned lockfile) doesn't flood the terminal / CI log; the full
9
+ // count is in the header and the remainder is summarized at the end.
10
+ const MAX_VIOLATIONS_TO_PRINT = 20;
11
+ // 16 mirrors the floor of pnpm's package-requester network-concurrency
12
+ // (Math.min(64, Math.max(workers*3, 16))); keep them aligned so the
13
+ // verification pass doesn't push past what the rest of the install respects.
14
+ const DEFAULT_CONCURRENCY = 16;
15
+ /**
16
+ * Policy-neutral pass that asks every resolver-supplied
17
+ * {@link ResolutionVerifier} to check every entry in a lockfile loaded
18
+ * from disk. Iteration runs before resolution decisions are touched and
19
+ * before any tarball is fetched, so a lockfile whose entries were
20
+ * resolved elsewhere (committed to the repo, restored from a cache,
21
+ * etc.) under a weaker or absent policy cannot reach the filesystem.
22
+ * Fresh local resolution is covered by the resolver's own per-version
23
+ * filter.
24
+ *
25
+ * Each verifier handles its own protocol short-circuit inside `verify`
26
+ * (returning `{ ok: true }` for resolutions outside its scope), so the
27
+ * fan-out is policy-neutral and dispatch-free at this layer.
28
+ *
29
+ * Designed for fail-closed semantics at the verifier level: a verifier
30
+ * that can't confirm a resolution is expected to return `{ ok: false }`
31
+ * rather than passing silently — otherwise a registry hiccup or an
32
+ * unpublished version would re-open the bypass.
33
+ *
34
+ * No-op when `verifiers` is empty.
35
+ *
36
+ * When `options.cacheDir` and `options.lockfilePath` are both
37
+ * provided, an unchanged lockfile that has already been verified
38
+ * under the same (or stricter) policy short-circuits the registry
39
+ * round-trip entirely — see {@link tryLockfileVerificationCache} for
40
+ * the lookup logic.
41
+ */
42
+ export async function verifyLockfileResolutions(lockfile, verifiers, options) {
43
+ if (verifiers.length === 0)
44
+ return;
45
+ if (!lockfile.packages)
46
+ return;
47
+ // Caching kicks in only when the caller surfaced both a writable
48
+ // cache directory and the lockfile's absolute path — that's the
49
+ // production wiring; unit tests that skip them get the gate without
50
+ // memoization and still exercise the same code path.
51
+ const cache = options?.cacheDir && options?.lockfilePath
52
+ ? { cacheDir: options.cacheDir, lockfilePath: options.lockfilePath }
53
+ : undefined;
54
+ let cachePrecomputed;
55
+ // hashObject streams and is key-order-stable, unlike JSON.stringify.
56
+ let cachedHash;
57
+ const hashLockfile = () => {
58
+ if (cachedHash == null)
59
+ cachedHash = hashObject(lockfile);
60
+ return cachedHash;
61
+ };
62
+ if (cache) {
63
+ const result = tryLockfileVerificationCache(cache.cacheDir, {
64
+ lockfilePath: cache.lockfilePath,
65
+ verifiers,
66
+ hashLockfile,
67
+ });
68
+ if (result.hit)
69
+ return;
70
+ cachePrecomputed = result.precomputed;
71
+ }
72
+ // Emit started/done around the actual verification pass — the
73
+ // round-trip can be slow on a cold registry cache, and the cached
74
+ // short-circuit above doesn't reach this branch, so a user only
75
+ // sees these messages on installs that are doing real work.
76
+ // A degenerate lockfile where every snapshot fails the
77
+ // name/version extraction (so candidates is empty) skips emission
78
+ // entirely — no work, no noise.
79
+ const candidates = collectCandidates(lockfile);
80
+ if (candidates.size === 0) {
81
+ if (cache) {
82
+ recordVerification(cache.cacheDir, {
83
+ lockfilePath: cache.lockfilePath,
84
+ verifiers,
85
+ hashLockfile,
86
+ }, cachePrecomputed);
87
+ }
88
+ return;
89
+ }
90
+ const startedAt = Date.now();
91
+ lockfileVerificationLogger.debug({
92
+ status: 'started',
93
+ entries: candidates.size,
94
+ lockfilePath: options?.lockfilePath,
95
+ });
96
+ // Guarantee a terminal `done` or `failed` event on every exit path
97
+ // that emitted `started`. Without this, an unexpected throw from the
98
+ // registry fan-out (or the policy-violation throw below) would leave
99
+ // the transient "Verifying lockfile…" line as the last frame the
100
+ // reporter rendered for this block, hanging spinner-style above the
101
+ // failure output.
102
+ let terminalStatus = 'failed';
103
+ try {
104
+ const violations = await iterateLockfileViolations(candidates, verifiers, options?.concurrency);
105
+ if (violations.length === 0) {
106
+ terminalStatus = 'done';
107
+ // Persist the success so the next install can stat-only the lockfile.
108
+ if (cache) {
109
+ recordVerification(cache.cacheDir, {
110
+ lockfilePath: cache.lockfilePath,
111
+ verifiers,
112
+ hashLockfile,
113
+ }, cachePrecomputed);
114
+ }
115
+ return;
116
+ }
117
+ throw buildVerificationError(violations);
118
+ }
119
+ finally {
120
+ lockfileVerificationLogger.debug({
121
+ status: terminalStatus,
122
+ entries: candidates.size,
123
+ elapsedMs: Date.now() - startedAt,
124
+ lockfilePath: options?.lockfilePath,
125
+ });
126
+ }
127
+ }
128
+ function buildVerificationError(violations) {
129
+ // Stable order so the error output is deterministic.
130
+ violations.sort((a, b) => `${a.name}@${a.version}`.localeCompare(`${b.name}@${b.version}`));
131
+ // Pick the throw code: a single-code batch keeps the per-policy code
132
+ // (so existing handlers / docs / search keywords still route correctly);
133
+ // a mixed batch (e.g. minimumReleaseAge + trust-downgrade on the same
134
+ // lockfile) escalates to the generic `LOCKFILE_RESOLUTION_VERIFICATION`
135
+ // and the per-entry code goes into the breakdown so the user can see
136
+ // which policy each entry tripped.
137
+ const distinctCodes = new Set(violations.map((v) => v.code));
138
+ const isMixed = distinctCodes.size > 1;
139
+ const errorCode = isMixed ? 'LOCKFILE_RESOLUTION_VERIFICATION' : violations[0].code;
140
+ const visible = violations.slice(0, MAX_VIOLATIONS_TO_PRINT);
141
+ const omitted = violations.length - visible.length;
142
+ const formatEntry = isMixed
143
+ ? (v) => ` ${v.name}@${v.version} [${v.code}] ${v.reason}`
144
+ : (v) => ` ${v.name}@${v.version} ${v.reason}`;
145
+ const breakdown = visible.map(formatEntry).join('\n');
146
+ const details = omitted > 0
147
+ ? `${breakdown}\n …and ${omitted} more`
148
+ : breakdown;
149
+ return new PnpmError(errorCode, `${violations.length} lockfile entries failed verification:\n${details}`, {
150
+ hint: 'The lockfile contains entries that the active policies reject. ' +
151
+ 'This can mean the lockfile is stale, or that someone committed a ' +
152
+ 'lockfile that bypassed the policy locally — inspect recent changes ' +
153
+ 'to pnpm-lock.yaml before trusting it. If the changes look expected, ' +
154
+ 'run "pnpm clean --lockfile" and then "pnpm install" to rebuild from ' +
155
+ 'a fresh resolution. Alternatively, relax the policy that flagged ' +
156
+ 'them.',
157
+ });
158
+ }
159
+ /**
160
+ * Collect-mode sibling of {@link verifyLockfileResolutions}: runs the
161
+ * same fan-out over every verifier and every lockfile entry, but
162
+ * returns the violations as data instead of throwing on the first batch.
163
+ * No cache lookup or write — the throw-mode `verifyLockfileResolutions`
164
+ * is what populates / honors the cache; this is for callers that need
165
+ * to inspect violations (auto-collect into `minimumReleaseAgeExclude`,
166
+ * the strict-mode interactive prompt, future resolver-specific
167
+ * policies).
168
+ *
169
+ * Returns an empty array when `verifiers` is empty or the lockfile has
170
+ * no packages, so callers don't need a separate emptiness check.
171
+ */
172
+ export async function collectResolutionPolicyViolations(lockfile, verifiers, options) {
173
+ if (verifiers.length === 0)
174
+ return [];
175
+ if (!lockfile.packages)
176
+ return [];
177
+ return iterateLockfileViolations(collectCandidates(lockfile), verifiers, options?.concurrency);
178
+ }
179
+ // depPath can include peer-dependency and patch_hash suffixes (e.g.
180
+ // `react@18.0.0(peer)(patch_hash=…)`); the same (name, version) pair may
181
+ // therefore appear multiple times. Dedupe so we issue at most one
182
+ // verification per package version.
183
+ //
184
+ // Include a serialization of `resolution` in the key so two entries that
185
+ // share a (name, version) but differ in *what* was resolved (e.g. one
186
+ // pinned via npm, another via a git URL under the same alias) don't
187
+ // collapse: if the wrong shape wins the dedup, a protocol-scoped
188
+ // verifier short-circuits on the surviving entry and the real one is
189
+ // never checked.
190
+ function collectCandidates(lockfile) {
191
+ const candidates = new Map();
192
+ for (const [depPath, snapshot] of Object.entries(lockfile.packages ?? {})) {
193
+ const { name, version } = nameVerFromPkgSnapshot(depPath, snapshot);
194
+ if (!name || !version)
195
+ continue;
196
+ const key = `${name}@${version}@${JSON.stringify(snapshot.resolution)}`;
197
+ candidates.set(key, {
198
+ name,
199
+ version,
200
+ resolution: snapshot.resolution,
201
+ });
202
+ }
203
+ return candidates;
204
+ }
205
+ async function iterateLockfileViolations(candidates, verifiers, concurrency) {
206
+ const violations = [];
207
+ const limit = pLimit(concurrency ?? DEFAULT_CONCURRENCY);
208
+ await Promise.all(Array.from(candidates.values(), ({ name, version, resolution }) => limit(async () => {
209
+ // Fan out across every active verifier; each handles its own
210
+ // protocol short-circuit (e.g. the npm verifier returns ok:true for
211
+ // git resolutions). We stop at the first failure per entry so a
212
+ // multi-verifier setup doesn't produce duplicate violations for the
213
+ // same (name, version).
214
+ for (const verifier of verifiers) {
215
+ // eslint-disable-next-line no-await-in-loop
216
+ const result = await verifier.verify(resolution, { name, version });
217
+ if (!result.ok) {
218
+ violations.push({ name, version, resolution, code: result.code, reason: result.reason });
219
+ break;
220
+ }
221
+ }
222
+ })));
223
+ return violations;
224
+ }
225
+ //# sourceMappingURL=verifyLockfileResolutions.js.map
@@ -0,0 +1,80 @@
1
+ import type { ResolutionVerifier } from '@pnpm/resolving.resolver-base';
2
+ /**
3
+ * Subset of {@link ResolutionVerifier} the cache layer needs: the
4
+ * verifier's `policy` contribution plus the `canTrustPastCheck`
5
+ * comparator. `verify` is intentionally absent — the cache never runs
6
+ * verifiers, it just decides whether a previous run is still
7
+ * trustworthy.
8
+ */
9
+ export type VerifierCacheIdentity = Pick<ResolutionVerifier, 'policy' | 'canTrustPastCheck'>;
10
+ export interface CacheLookupResult {
11
+ hit: boolean;
12
+ /**
13
+ * stat + hash already computed during the lookup. When the caller
14
+ * follows up with {@link recordVerification} after running the gate,
15
+ * passing these back avoids re-stat'ing and (especially) re-hashing
16
+ * the lockfile a second time. Fields are undefined when the lookup
17
+ * couldn't (or didn't need to) compute them — `recordVerification`
18
+ * falls back to computing what's missing.
19
+ */
20
+ precomputed: {
21
+ stat?: LockfileStat;
22
+ hash?: string;
23
+ };
24
+ }
25
+ interface LockfileStat {
26
+ size: number;
27
+ mtimeNs: string;
28
+ inode: string;
29
+ }
30
+ export interface LockfileVerificationCacheKey {
31
+ lockfilePath: string;
32
+ verifiers: readonly VerifierCacheIdentity[];
33
+ /**
34
+ * Lazy: returns a stable hex hash of the in-memory lockfile. The
35
+ * cache invokes this only when the stat shortcut doesn't apply (the
36
+ * lockfile is at a new path, or its stat has drifted from the
37
+ * cached record). When the stat shortcut hits, the in-memory hash is
38
+ * never computed.
39
+ */
40
+ hashLockfile: () => string;
41
+ }
42
+ /**
43
+ * Try to confirm a cached verification covers the lockfile as it
44
+ * currently sits on disk and the policies currently in effect. Returns
45
+ * `{ hit: true }` to skip the gate; `{ hit: false }` means the caller
46
+ * should run the verifier and persist the result with
47
+ * {@link recordVerification}.
48
+ *
49
+ * Lookup order:
50
+ *
51
+ * 1. **Stat shortcut** — if we've previously verified this exact path
52
+ * with these exact stat values, trust the cached hash and skip
53
+ * reading the lockfile.
54
+ * 2. **Content lookup** — hash the lockfile and look up by hash.
55
+ * Catches the worktree case (same content, different path) and
56
+ * CI checkouts where stat fields got reset. Refreshes the
57
+ * stat-shortcut entry on hit so the next install at this path
58
+ * skips the hash.
59
+ *
60
+ * Every active verifier must agree the cached policy snapshot is still
61
+ * trustworthy under what it currently demands; if any rejects, the
62
+ * full gate runs.
63
+ */
64
+ export declare function tryLockfileVerificationCache(cacheDir: string, key: LockfileVerificationCacheKey): CacheLookupResult;
65
+ /**
66
+ * Persist a successful verification. Called after the gate passes; the
67
+ * lockfile is hashed once and the resulting record is appended to the
68
+ * cache file. If the file is past {@link MAX_CACHE_ENTRIES}, it is
69
+ * rewritten keeping the most recent entries.
70
+ *
71
+ * Reuses `precomputed` values from a prior
72
+ * {@link tryLockfileVerificationCache} lookup so we don't re-stat or
73
+ * (especially) re-hash the lockfile a second time on the miss-then-
74
+ * record path.
75
+ */
76
+ export declare function recordVerification(cacheDir: string, key: LockfileVerificationCacheKey, precomputed?: {
77
+ stat?: LockfileStat;
78
+ hash?: string;
79
+ }): void;
80
+ export {};
@@ -0,0 +1,335 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import util from 'node:util';
4
+ import { logger } from '@pnpm/logger';
5
+ /**
6
+ * On-disk cache of verifyLockfileResolutions results, keyed by lockfile
7
+ * content hash. Lets repeat installs against an unchanged lockfile skip
8
+ * the per-package registry round trips entirely — including across git
9
+ * worktrees, where the same lockfile content lives at different paths.
10
+ *
11
+ * Two indexes share the same JSONL records:
12
+ *
13
+ * - **by content hash** — the primary index. Recognizing the same
14
+ * lockfile content regardless of where it sits on disk is what makes
15
+ * worktrees and lockfile copies hit.
16
+ * - **by absolute path** — a same-machine stat shortcut. When we've
17
+ * seen this exact path before with these exact stat values, we
18
+ * trust the cached hash and skip reading the lockfile entirely
19
+ * (microseconds vs. ms-per-MB). Worktrees that get reinstalled in
20
+ * pay the hash cost once, then hit the stat fast path.
21
+ *
22
+ * All filesystem operations are synchronous: the cache is consulted
23
+ * once before verification fan-out and recorded once after — there's
24
+ * no concurrent install work to overlap with, so blocking the event
25
+ * loop for the brief read/stat/hash is fine and keeps the call sites
26
+ * straight-line.
27
+ *
28
+ * Persisted as JSON Lines: each verification appends one record;
29
+ * later records overwrite earlier ones on key collision when read.
30
+ * Appends of a single line are atomic on POSIX and NTFS, so parallel
31
+ * pnpm processes (monorepo installs, CI matrices sharing a cache) can
32
+ * write without coordination.
33
+ *
34
+ * Policy-neutral. Every active verifier's `policy` contribution merges
35
+ * into a single `policy` bag on the record; verifiers sharing a
36
+ * logical policy (same field name) share the slot — no resolver-level
37
+ * namespacing.
38
+ */
39
+ const CACHE_FILE_NAME = 'lockfile-verified.jsonl';
40
+ // Cap the file before it grows large enough to slow down reads. When the
41
+ // cap is exceeded we rewrite the file keeping the N most recently
42
+ // verified entries. The number is generous — a developer machine that
43
+ // touches a thousand distinct (path, content) tuples is far past steady
44
+ // state.
45
+ const MAX_CACHE_ENTRIES = 1000;
46
+ // Records cluster around 250–400 bytes; budget 1 KiB per entry as a
47
+ // conservative upper bound. The compaction check uses `stat().size` to
48
+ // decide whether to read+rewrite, so we never parse the file unless it
49
+ // has actually grown past the cap.
50
+ const COMPACT_TRIGGER_BYTES = MAX_CACHE_ENTRIES * 1024 * 3 / 2;
51
+ /**
52
+ * Build two indexes over the JSONL records in one pass: by content
53
+ * hash (primary) and by absolute path (stat shortcut). Records are
54
+ * walked in file order so the last record for any key wins.
55
+ */
56
+ function readCache(cacheDir) {
57
+ const cacheFilePath = path.join(cacheDir, CACHE_FILE_NAME);
58
+ let contents;
59
+ try {
60
+ contents = fs.readFileSync(cacheFilePath, 'utf8');
61
+ }
62
+ catch (err) {
63
+ if (isNodeError(err) && err.code === 'ENOENT')
64
+ return { byHash: new Map(), byPath: new Map() };
65
+ throw err;
66
+ }
67
+ const byHash = new Map();
68
+ const byPath = new Map();
69
+ for (const line of contents.split('\n')) {
70
+ if (!line)
71
+ continue;
72
+ try {
73
+ const parsed = JSON.parse(line);
74
+ const hash = parsed?.lockfile?.hash;
75
+ const lockfilePath = parsed?.lockfile?.path;
76
+ if (typeof hash !== 'string' || typeof lockfilePath !== 'string')
77
+ continue;
78
+ const record = normalizeRecord(parsed);
79
+ byHash.set(hash, record);
80
+ byPath.set(lockfilePath, record);
81
+ }
82
+ catch {
83
+ // Skip malformed lines; the next clean append will still work.
84
+ }
85
+ }
86
+ return { byHash, byPath };
87
+ }
88
+ function normalizeRecord(parsed) {
89
+ const lockfile = parsed.lockfile ?? {};
90
+ return {
91
+ lockfile: {
92
+ hash: lockfile.hash ?? '',
93
+ path: lockfile.path ?? '',
94
+ size: lockfile.size ?? -1,
95
+ mtimeNs: lockfile.mtimeNs ?? '',
96
+ inode: lockfile.inode ?? '',
97
+ },
98
+ verifiedAt: parsed.verifiedAt ?? '',
99
+ policy: parsed.policy && typeof parsed.policy === 'object' ? parsed.policy : {},
100
+ };
101
+ }
102
+ function statLockfile(lockfilePath) {
103
+ try {
104
+ const stat = fs.statSync(lockfilePath, { bigint: true });
105
+ return {
106
+ size: Number(stat.size),
107
+ mtimeNs: stat.mtimeNs.toString(),
108
+ inode: stat.ino.toString(),
109
+ };
110
+ }
111
+ catch (err) {
112
+ if (isNodeError(err) && err.code === 'ENOENT')
113
+ return null;
114
+ throw err;
115
+ }
116
+ }
117
+ function statMatches(stat, lockfile) {
118
+ return stat.size === lockfile.size &&
119
+ stat.mtimeNs === lockfile.mtimeNs &&
120
+ stat.inode === lockfile.inode;
121
+ }
122
+ /**
123
+ * Try to confirm a cached verification covers the lockfile as it
124
+ * currently sits on disk and the policies currently in effect. Returns
125
+ * `{ hit: true }` to skip the gate; `{ hit: false }` means the caller
126
+ * should run the verifier and persist the result with
127
+ * {@link recordVerification}.
128
+ *
129
+ * Lookup order:
130
+ *
131
+ * 1. **Stat shortcut** — if we've previously verified this exact path
132
+ * with these exact stat values, trust the cached hash and skip
133
+ * reading the lockfile.
134
+ * 2. **Content lookup** — hash the lockfile and look up by hash.
135
+ * Catches the worktree case (same content, different path) and
136
+ * CI checkouts where stat fields got reset. Refreshes the
137
+ * stat-shortcut entry on hit so the next install at this path
138
+ * skips the hash.
139
+ *
140
+ * Every active verifier must agree the cached policy snapshot is still
141
+ * trustworthy under what it currently demands; if any rejects, the
142
+ * full gate runs.
143
+ */
144
+ export function tryLockfileVerificationCache(cacheDir, key) {
145
+ let indexes;
146
+ try {
147
+ indexes = readCache(cacheDir);
148
+ }
149
+ catch (err) {
150
+ // A corrupt cache file should never block the install; fall
151
+ // through to verification so the gate still runs.
152
+ logger.debug({ msg: 'lockfile-verified cache: read failed', err });
153
+ return { hit: false, precomputed: {} };
154
+ }
155
+ const stat = statLockfile(key.lockfilePath);
156
+ if (!stat)
157
+ return { hit: false, precomputed: {} };
158
+ // Stat shortcut: same path + same stat means we trust the cached
159
+ // hash without reading the file. Microseconds.
160
+ const byPathRecord = indexes.byPath.get(key.lockfilePath);
161
+ if (byPathRecord && statMatches(stat, byPathRecord.lockfile)) {
162
+ return {
163
+ hit: everyVerifierTrustsCachedRun(byPathRecord, key.verifiers),
164
+ // The stat-match implies the file content is unchanged since the
165
+ // cached record was written, so its hash is still correct. Pass
166
+ // it through to skip hashing on the miss-then-record path.
167
+ precomputed: { stat, hash: byPathRecord.lockfile.hash },
168
+ };
169
+ }
170
+ // Content lookup: hash the in-memory lockfile, look up by content
171
+ // hash. Catches worktrees (same content, different path) and CI
172
+ // checkouts (same content, reset stat). On hit, refresh the
173
+ // path/stat entry so the next install at this path takes the stat
174
+ // shortcut above.
175
+ let hash;
176
+ try {
177
+ hash = key.hashLockfile();
178
+ }
179
+ catch (err) {
180
+ logger.debug({ msg: 'lockfile-verified cache: lockfile hash failed', err });
181
+ return { hit: false, precomputed: { stat } };
182
+ }
183
+ const byHashRecord = indexes.byHash.get(hash);
184
+ if (!byHashRecord)
185
+ return { hit: false, precomputed: { stat, hash } };
186
+ if (!everyVerifierTrustsCachedRun(byHashRecord, key.verifiers)) {
187
+ return { hit: false, precomputed: { stat, hash } };
188
+ }
189
+ appendRecord(cacheDir, {
190
+ ...byHashRecord,
191
+ lockfile: { ...byHashRecord.lockfile, path: key.lockfilePath, size: stat.size, mtimeNs: stat.mtimeNs, inode: stat.inode },
192
+ });
193
+ return { hit: true, precomputed: { stat, hash } };
194
+ }
195
+ function everyVerifierTrustsCachedRun(record, verifiers) {
196
+ for (const verifier of verifiers) {
197
+ if (!verifier.canTrustPastCheck(record.policy))
198
+ return false;
199
+ }
200
+ return true;
201
+ }
202
+ function mergePolicies(verifiers) {
203
+ // Later verifiers overwrite earlier ones on conflict — a shared field
204
+ // should carry the same value across verifiers by convention; mismatch
205
+ // is a config bug and we don't try to reconcile it here.
206
+ const merged = {};
207
+ for (const verifier of verifiers) {
208
+ Object.assign(merged, verifier.policy);
209
+ }
210
+ return merged;
211
+ }
212
+ /**
213
+ * Persist a successful verification. Called after the gate passes; the
214
+ * lockfile is hashed once and the resulting record is appended to the
215
+ * cache file. If the file is past {@link MAX_CACHE_ENTRIES}, it is
216
+ * rewritten keeping the most recent entries.
217
+ *
218
+ * Reuses `precomputed` values from a prior
219
+ * {@link tryLockfileVerificationCache} lookup so we don't re-stat or
220
+ * (especially) re-hash the lockfile a second time on the miss-then-
221
+ * record path.
222
+ */
223
+ export function recordVerification(cacheDir, key, precomputed) {
224
+ let stat;
225
+ let hash;
226
+ try {
227
+ stat = precomputed?.stat ?? statLockfile(key.lockfilePath);
228
+ if (!stat)
229
+ return;
230
+ hash = precomputed?.hash ?? key.hashLockfile();
231
+ }
232
+ catch (err) {
233
+ // The gate has already passed; if we can't record the cache entry we
234
+ // just won't get the speedup next time. Not a reason to fail install.
235
+ logger.debug({ msg: 'lockfile-verified cache: could not record verification', err });
236
+ return;
237
+ }
238
+ const record = {
239
+ lockfile: {
240
+ hash,
241
+ path: key.lockfilePath,
242
+ size: stat.size,
243
+ mtimeNs: stat.mtimeNs,
244
+ inode: stat.inode,
245
+ },
246
+ verifiedAt: new Date().toISOString(),
247
+ policy: mergePolicies(key.verifiers),
248
+ };
249
+ appendRecord(cacheDir, record);
250
+ }
251
+ function appendRecord(cacheDir, record) {
252
+ const cacheFilePath = path.join(cacheDir, CACHE_FILE_NAME);
253
+ const line = `${JSON.stringify(record)}\n`;
254
+ try {
255
+ fs.mkdirSync(cacheDir, { recursive: true });
256
+ fs.appendFileSync(cacheFilePath, line);
257
+ }
258
+ catch (err) {
259
+ logger.debug({ msg: 'lockfile-verified cache: append failed', err });
260
+ return;
261
+ }
262
+ maybeCompactCache(cacheDir);
263
+ }
264
+ function maybeCompactCache(cacheDir) {
265
+ const cacheFilePath = path.join(cacheDir, CACHE_FILE_NAME);
266
+ // Decide whether to compact from the file size alone — avoids reading
267
+ // and parsing the file on every successful install. Records cluster
268
+ // around a few hundred bytes; the byte budget translates directly to
269
+ // the entry cap with generous slack so we don't trigger a rewrite on
270
+ // every append once we cross the line.
271
+ let size;
272
+ try {
273
+ size = fs.statSync(cacheFilePath).size;
274
+ }
275
+ catch (err) {
276
+ if (isNodeError(err) && err.code === 'ENOENT')
277
+ return;
278
+ logger.debug({ msg: 'lockfile-verified cache: stat for compaction failed', err });
279
+ return;
280
+ }
281
+ if (size <= COMPACT_TRIGGER_BYTES)
282
+ return;
283
+ let contents;
284
+ try {
285
+ contents = fs.readFileSync(cacheFilePath, 'utf8');
286
+ }
287
+ catch (err) {
288
+ if (isNodeError(err) && err.code === 'ENOENT')
289
+ return;
290
+ logger.debug({ msg: 'lockfile-verified cache: read for compaction failed', err });
291
+ return;
292
+ }
293
+ const lines = contents.split('\n').filter(Boolean);
294
+ // Dedup by (path, hash) — that's the unit both indexes care about.
295
+ // Walking reverse keeps the newest record per tuple; we then trim to
296
+ // MAX_CACHE_ENTRIES and write back in original order.
297
+ const seen = new Set();
298
+ const reversed = [];
299
+ for (let i = lines.length - 1; i >= 0; i--) {
300
+ const line = lines[i];
301
+ try {
302
+ const parsed = JSON.parse(line);
303
+ const lockfilePath = parsed?.lockfile?.path;
304
+ const hash = parsed?.lockfile?.hash;
305
+ if (typeof lockfilePath !== 'string' || typeof hash !== 'string')
306
+ continue;
307
+ const tupleKey = `${lockfilePath}${hash}`;
308
+ if (seen.has(tupleKey))
309
+ continue;
310
+ seen.add(tupleKey);
311
+ reversed.push(line);
312
+ }
313
+ catch {
314
+ // Skip malformed lines.
315
+ }
316
+ }
317
+ reversed.reverse();
318
+ const kept = reversed.slice(-MAX_CACHE_ENTRIES);
319
+ try {
320
+ // Write to a sibling tempfile + rename so a concurrent pnpm process
321
+ // can't observe a half-written file.
322
+ const tmpPath = `${cacheFilePath}.${process.pid}.tmp`;
323
+ fs.writeFileSync(tmpPath, kept.map((line) => `${line}\n`).join(''));
324
+ fs.renameSync(tmpPath, cacheFilePath);
325
+ }
326
+ catch (err) {
327
+ logger.debug({ msg: 'lockfile-verified cache: compaction failed', err });
328
+ }
329
+ }
330
+ function isNodeError(err) {
331
+ // `instanceof Error` is unreliable across realms (Jest's VM context), so
332
+ // route through util.types.isNativeError per the repo guideline.
333
+ return util.types.isNativeError(err) && 'code' in err;
334
+ }
335
+ //# sourceMappingURL=verifyLockfileResolutionsCache.js.map
@@ -0,0 +1,14 @@
1
+ import { type LockfileObject, type WriteLockfilesResult } from '@pnpm/lockfile.fs';
2
+ import type { ResolutionVerifier } from '@pnpm/resolving.resolver-base';
3
+ export interface WriteLockfilesAndRecordVerifiedOptions {
4
+ wantedLockfile: LockfileObject;
5
+ wantedLockfileDir: string;
6
+ currentLockfile: LockfileObject;
7
+ currentLockfileDir: string;
8
+ useGitBranchLockfile?: boolean;
9
+ mergeGitBranchLockfiles?: boolean;
10
+ cacheDir?: string;
11
+ resolutionVerifiers: readonly ResolutionVerifier[] | undefined;
12
+ }
13
+ /** Plural counterpart of {@link writeWantedLockfileAndRecordVerified}. */
14
+ export declare function writeLockfilesAndRecordVerified(opts: WriteLockfilesAndRecordVerifiedOptions): Promise<WriteLockfilesResult>;
@@ -0,0 +1,32 @@
1
+ import path from 'node:path';
2
+ import { getWantedLockfileName, writeLockfiles } from '@pnpm/lockfile.fs';
3
+ import { recordLockfileVerified } from './recordLockfileVerified.js';
4
+ /** Plural counterpart of {@link writeWantedLockfileAndRecordVerified}. */
5
+ export async function writeLockfilesAndRecordVerified(opts) {
6
+ const cacheActive = opts.cacheDir != null && (opts.resolutionVerifiers?.length ?? 0) > 0;
7
+ const wantedLockfileName = cacheActive
8
+ ? await getWantedLockfileName({
9
+ useGitBranchLockfile: opts.useGitBranchLockfile,
10
+ mergeGitBranchLockfiles: opts.mergeGitBranchLockfiles,
11
+ })
12
+ : undefined;
13
+ const written = await writeLockfiles({
14
+ wantedLockfile: opts.wantedLockfile,
15
+ wantedLockfileDir: opts.wantedLockfileDir,
16
+ currentLockfile: opts.currentLockfile,
17
+ currentLockfileDir: opts.currentLockfileDir,
18
+ useGitBranchLockfile: opts.useGitBranchLockfile,
19
+ mergeGitBranchLockfiles: opts.mergeGitBranchLockfiles,
20
+ wantedLockfileName,
21
+ });
22
+ if (cacheActive) {
23
+ recordLockfileVerified({
24
+ cacheDir: opts.cacheDir,
25
+ lockfilePath: path.resolve(opts.wantedLockfileDir, wantedLockfileName),
26
+ lockfile: written.wantedLockfile,
27
+ resolutionVerifiers: opts.resolutionVerifiers,
28
+ });
29
+ }
30
+ return written;
31
+ }
32
+ //# sourceMappingURL=writeLockfilesAndRecordVerified.js.map