@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.
- package/lib/install/extendInstallOptions.d.ts +34 -1
- package/lib/install/extendInstallOptions.js +1 -0
- package/lib/install/index.d.ts +14 -1
- package/lib/install/index.js +82 -12
- package/lib/install/link.js +7 -1
- package/lib/install/recordLockfileVerified.d.ts +22 -0
- package/lib/install/recordLockfileVerified.js +24 -0
- package/lib/install/verifyLockfileResolutions.d.ts +59 -0
- package/lib/install/verifyLockfileResolutions.js +225 -0
- package/lib/install/verifyLockfileResolutionsCache.d.ts +80 -0
- package/lib/install/verifyLockfileResolutionsCache.js +335 -0
- package/lib/install/writeLockfilesAndRecordVerified.d.ts +14 -0
- package/lib/install/writeLockfilesAndRecordVerified.js +32 -0
- package/lib/install/writeWantedLockfileAndRecordVerified.d.ts +12 -0
- package/lib/install/writeWantedLockfileAndRecordVerified.js +28 -0
- package/package.json +46 -46
|
@@ -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
|