Skip to content

Commit fffa2f4

Browse files
fengmk2arkmartaclaude
committed
fix(pm): verify and content-key managed package-manager installs
Verify a downloaded managed package manager (pnpm, Yarn 2+, bun, and native pnpm 12+) against the integrity the project declares in its `packageManager` field -- or, for the pnpm global setup bootstraps, against the hash the CLI carries -- rather than the integrity the registry reports. Cache a verified install in a content-addressed directory keyed by that pin (and, for a platform-binary manager, the registry origin), under a hidden `.verified-v1` namespace that only a verified download writes to; an unpinned install stays at the version-directory root. A different pin resolves to a different, verified directory. - Reuse a complete keyed directory without re-reading the pin; a different pin triggers a fresh, verified download. A complete install is never replaced. - Skip link entries when extracting package-manager archives, as npm does, so a tarball writes only regular files into its own install directory. - Keep the Windows `.cmd` fallback on the shim's cached fast path. - `vp env which`/`current`/`doctor`/`list`/`clean`/`uninstall` understand both the unpinned version-directory root and the verified namespace. Co-Authored-By: arkmarta <arkadiuszmarta@proton.me> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8c97ad7 commit fffa2f4

33 files changed

Lines changed: 1733 additions & 416 deletions

‎Cargo.lock‎

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/homebrew.mjs‎

Lines changed: 79 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ const originalEnv = Object.fromEntries(
3434
);
3535
const token = 'synthetic-homebrew-token';
3636
const pin = '10.34.7';
37+
// short_fingerprint(PINNED_PNPM_INTEGRITY) in crates/vp_setup/src/install.rs (the
38+
// first 32 hex of its SHA-256). The bundled pnpm is pinned, so setup verifies its
39+
// download against that integrity; a fake tarball cannot match it, so tests seed
40+
// the stub at the pinned install's content key instead.
41+
const SETUP_PNPM_KEY = '2f9ea5a98bd92b2d1d6694aedb029783';
3742
const lockfile = "lockfileVersion: '9.0'\n";
3843
fs.writeFileSync(
3944
path.join(root, 'package.json'),
@@ -191,7 +196,47 @@ function keg(version) {
191196
return prefix;
192197
}
193198

194-
function environment(name, auth = true, seedRuntime = true, seedInstallRuntime = seedRuntime) {
199+
// Write a runnable stub pnpm install under `dataDir`, at both the pinned install's
200+
// content key and the version-directory root (for the unpinned `pn`/`pnx` shim),
201+
// so neither the setup bootstrap nor the shim downloads it.
202+
function seedStubPnpm(dataDir) {
203+
for (const key of [SETUP_PNPM_KEY, null]) {
204+
// A pinned install lives under the hidden verified namespace; the unpinned
205+
// install stays at the version-directory root.
206+
const install = key
207+
? path.join(dataDir, 'package_manager/pnpm/.verified-v1', pin, key, 'pnpm')
208+
: path.join(dataDir, 'package_manager/pnpm', pin, 'pnpm');
209+
const bin = path.join(install, 'bin');
210+
fs.mkdirSync(bin, { recursive: true });
211+
fs.writeFileSync(
212+
path.join(install, 'package.json'),
213+
JSON.stringify({
214+
name: 'pnpm',
215+
version: pin,
216+
engines: { node: '>=18.12' },
217+
bin: { pnpm: 'bin/pnpm.cjs', pnpx: 'bin/pnpx.cjs' },
218+
}),
219+
);
220+
for (const tool of ['pnpm', 'pnpx']) {
221+
fs.writeFileSync(path.join(bin, `${tool}.cjs`), pnpmCode);
222+
fs.writeFileSync(path.join(bin, tool), `#!/bin/sh\nexec node "$(dirname "$0")/${tool}.cjs" "$@"\n`, {
223+
mode: 0o755,
224+
});
225+
}
226+
}
227+
}
228+
229+
function environment(
230+
name,
231+
auth = true,
232+
seedRuntime = true,
233+
seedInstallRuntime = seedRuntime,
234+
// Default to `auth`: an unauthenticated scenario's failure is at the
235+
// auth-protected pnpm download, so it must download (and 401) rather than reuse
236+
// a seed. Authenticated scenarios seed so setup reuses pnpm instead of failing
237+
// the integrity pin on a fake download.
238+
seedPnpm = auth,
239+
) {
195240
const home = path.join(root, name);
196241
const vpHome = path.join(home, '.vite-plus');
197242
fs.mkdirSync(home, { recursive: true });
@@ -212,6 +257,7 @@ function environment(name, auth = true, seedRuntime = true, seedInstallRuntime =
212257
// Existing installations already have their tool and dependency-install runtimes.
213258
fs.writeFileSync(path.join(vpHome, '.vp-owned'), 'vite-plus-owned-directory\n');
214259
}
260+
if (seedPnpm) seedStubPnpm(vpHome);
215261
fs.writeFileSync(
216262
path.join(home, '.npmrc'),
217263
`registry=${url}/registry/\n${auth ? `//127.0.0.1:${server.address().port}/registry/:_authToken=\${NPM_TOKEN}\n` : ''}`,
@@ -273,7 +319,7 @@ try {
273319
rejectInstall = false;
274320
const retry = await Promise.all([run(publicVp, ['--help'], env), run(publicVp, ['--help'], env)]);
275321
assert.equal(installs, 2, 'retry must install once across concurrent launches');
276-
assert.equal(downloads, 1, 'the retry must reuse bootstrapped pnpm');
322+
assert.equal(downloads, 0, 'setup and retry must reuse the provisioned pnpm, never downloading it');
277323
assert.equal(indexRequests, 0, 'dependency retries must also use the release pin');
278324
assert.ok(retry.every((result) => !result.stderr.includes('manage Node')));
279325
assert.equal(fs.readFileSync(settingsFile, 'utf8'), preferences);
@@ -584,7 +630,9 @@ try {
584630
}
585631

586632
for (const split of [false, true]) {
587-
const fresh = environment(`fresh-${split ? 'split' : 'single'}`, true, false);
633+
// seedPnpm = false: the fresh-root assertions below require an empty start, so
634+
// the stub pnpm is seeded inside the loop, after that check, instead.
635+
const fresh = environment(`fresh-${split ? 'split' : 'single'}`, true, false, false, false);
588636
const data = split ? path.join(fresh.HOME, 'data') : fresh.VP_HOME;
589637
const cache = split ? path.join(fresh.HOME, 'cache') : path.join(data, 'cache');
590638
const bin = split ? path.join(fresh.HOME, 'bin') : path.join(data, 'bin');
@@ -607,6 +655,11 @@ try {
607655
const npmrc = fs.readFileSync(path.join(fresh.HOME, '.npmrc'), 'utf8');
608656
for (let attempt = 0; attempt < 2; attempt++) {
609657
for (const directory of roots) assert.ok(!fs.existsSync(directory), directory);
658+
// Seed the stub pnpm now that the empty-start check has passed, so fresh
659+
// setup reuses it instead of downloading a fake tarball that fails the pin.
660+
// Mark the data root vp-owned so setup accepts the pre-seeded directory.
661+
seedStubPnpm(data);
662+
fs.writeFileSync(path.join(data, '.vp-owned'), 'vite-plus-owned-directory\n');
610663
const before = {
611664
installs,
612665
indexRequests,
@@ -642,7 +695,11 @@ try {
642695
['direct', `${url}/registry/`],
643696
['interpolated', '${TEST_REGISTRY}/registry/'],
644697
]) {
645-
const override = environment(`${name}-registry-override`);
698+
// seedPnpm = false: the override must carry the pnpm download to the
699+
// configured registry, so assert the protected request happens instead of
700+
// letting a seeded cache hide a broken override. The fixture's stub tarball
701+
// then fails the integrity pin, so setup stops with a nonzero status.
702+
const override = environment(`${name}-registry-override`, true, true, true, false);
646703
const config = path.join(override.HOME, '.npmrc');
647704
fs.writeFileSync(
648705
config,
@@ -651,7 +708,12 @@ try {
651708
.replace(`${url}/registry/`, 'http://127.0.0.1:1/unavailable/'),
652709
);
653710
override.NPM_CONFIG_REGISTRY = registry;
654-
await run(publicVp, ['--help'], override);
711+
const before = downloads;
712+
await run(publicVp, ['--help'], override, root, 1);
713+
assert.ok(
714+
downloads > before,
715+
`${name} registry override must carry the pnpm download to the configured registry`,
716+
);
655717
}
656718

657719
for (const [name, selector] of [
@@ -670,16 +732,22 @@ try {
670732
'Relative, tilde, and environment-variable selectors reach pnpm as the same absolute user config.',
671733
);
672734

673-
const basic = environment('basic-auth');
735+
// seedPnpm = false: the Basic-auth credential must carry the pnpm download, so
736+
// assert the protected request happens instead of letting a seeded cache pass
737+
// even when the credential is wrong. The fixture's stub tarball then fails the
738+
// integrity pin, so setup stops with a nonzero status.
739+
const basic = environment('basic-auth', true, true, true, false);
674740
const encoded = Buffer.from('fixture:synthetic-password').toString('base64');
675741
fs.writeFileSync(
676742
path.join(basic.HOME, '.npmrc'),
677743
`registry=${url}/registry/\n//127.0.0.1:${server.address().port}/registry/:_auth=${encoded}\n`,
678744
);
679745
authorization = `Basic ${encoded}`;
680-
await run(publicVp, ['--help'], basic);
746+
const beforeBasic = downloads;
747+
await run(publicVp, ['--help'], basic, root, 1);
748+
assert.ok(downloads > beforeBasic, 'basic authentication must carry the protected pnpm download');
681749
authorization = `Bearer ${token}`;
682-
console.log('Registry overrides and basic authentication work with the shared downloader.');
750+
console.log('Registry overrides and basic authentication reach the shared downloader.');
683751

684752
const unauthenticated = environment('unauthenticated', false);
685753
await run(publicVp, ['--help'], unauthenticated, root, 1);
@@ -729,7 +797,9 @@ try {
729797
assert.ok(registryRequests > requestsBeforeAlias, 'shim arguments must not bypass setup');
730798

731799
corrupt = true;
732-
await run(publicVp, ['--help'], environment('corrupt'), root, 1);
800+
// seedPnpm = false: this scenario needs a real (corrupt) pnpm download to verify
801+
// that a tarball failing verification stops setup.
802+
await run(publicVp, ['--help'], environment('corrupt', true, true, true, false), root, 1);
733803
console.log('Missing authentication and corrupted pnpm tarballs stop setup.');
734804
unlock(replacement);
735805
} finally {

‎crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots.toml‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ steps = [["node", "verify-refresh.mjs", "homebrew-sources"]]
88
name = "command_self_setup_homebrew_user_dependencies"
99
vp = "global"
1010
skip-platforms = ["windows"]
11-
steps = [{ argv = ["node", "homebrew.mjs"], timeout = 120000 }]
11+
# The heaviest external case: it drives ~15 full self-setup scenarios in one
12+
# `node homebrew.mjs` run, so it needs a wider timeout than the 120s default,
13+
# especially under parallel load.
14+
steps = [{ argv = ["node", "homebrew.mjs"], timeout = 300000 }]
1215

1316
[[case]]
1417
name = "command_self_setup_homebrew_collision"

‎crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_homebrew_user_dependencies.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ Cached Node, package managers, and global tools run without CLI dependency repai
1414
CLI aliases, explicit installers, and unconfigured shims still prepare CLI dependencies.
1515
User installations and cleanup stay independent.
1616
Relative, tilde, and environment-variable selectors reach pnpm as the same absolute user config.
17-
Registry overrides and basic authentication work with the shared downloader.
17+
Registry overrides and basic authentication reach the shared downloader.
1818
After authentication failure, diagnostics, upgrade guidance, and cleanup remain available.
1919
Missing authentication and corrupted pnpm tarballs stop setup.
2020
```

‎crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/install.mjs‎

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,12 @@ let runtimeDownloads = 0;
4343
let checksumRequests = 0;
4444
let indexRequests = 0;
4545

46-
const pnpmArchive = createArchive({
47-
'package/package.json': JSON.stringify({
48-
name: 'pnpm',
49-
version: '10.34.7',
50-
bin: { pnpm: 'bin/pnpm.cjs' },
51-
}),
52-
'package/bin/pnpm.cjs': `
46+
const pnpmManifest = JSON.stringify({
47+
name: 'pnpm',
48+
version: '10.34.7',
49+
bin: { pnpm: 'bin/pnpm.cjs' },
50+
});
51+
const pnpmCjs = `
5352
const fs = require('node:fs');
5453
const assert = require('node:assert/strict');
5554
assert.ok(process.argv.includes('--frozen-lockfile'));
@@ -61,9 +60,26 @@ const pnpmArchive = createArchive({
6160
fs.writeFileSync('node_modules/vite-plus/package.json', '{"name":"vite-plus"}');
6261
if (!response.ok) process.exit(17);
6362
});
64-
`,
63+
`;
64+
const pnpmArchive = createArchive({
65+
'package/package.json': pnpmManifest,
66+
'package/bin/pnpm.cjs': pnpmCjs,
6567
});
6668

69+
// The bundled pnpm is pinned, so setup verifies its download against the bundled
70+
// integrity. A fake tarball cannot match that pin, so seed the stub at the pinned
71+
// install's content key instead; the bootstrap reuses it and skips the download.
72+
// Key = short_fingerprint(PINNED_PNPM_INTEGRITY) in crates/vp_setup/src/install.rs
73+
// (the first 32 hex of its SHA-256).
74+
const SETUP_PNPM_KEY = '2f9ea5a98bd92b2d1d6694aedb029783';
75+
const seededPnpm = join(home, 'package_manager/pnpm/.verified-v1/10.34.7', SETUP_PNPM_KEY, 'pnpm');
76+
mkdirSync(join(seededPnpm, 'bin'), { recursive: true });
77+
writeFileSync(join(seededPnpm, 'package.json'), pnpmManifest);
78+
writeFileSync(join(seededPnpm, 'bin/pnpm.cjs'), pnpmCjs);
79+
for (const shim of ['pnpm', 'pnpm.cmd', 'pnpm.ps1']) {
80+
writeFileSync(join(seededPnpm, 'bin', shim), 'unused');
81+
}
82+
6783
// The request is made after the CLI draws each phase. Hold its response until
6884
// the runner captures the milestone, without sleeps or polling terminal text.
6985
const phases = [];
@@ -182,7 +198,7 @@ try {
182198
assert.equal(existsSync(marker), false);
183199
assert.equal(existsSync(receipt), resolutionRetry);
184200
assert.equal(existsSync(resolutionFile), false);
185-
assert.deepEqual(phases, ['download', 'prepare', 'dependencies']);
201+
assert.deepEqual(phases, ['prepare', 'dependencies']);
186202
console.log(
187203
resolutionRetry
188204
? 'A failed resolution-file write leaves dependencies installed and setup incomplete.'
@@ -220,8 +236,8 @@ try {
220236
assert.deepEqual(
221237
phases,
222238
dependencyRetry
223-
? ['download', 'prepare', 'dependencies', 'dependencies']
224-
: ['download', 'prepare', 'dependencies'],
239+
? ['prepare', 'dependencies', 'dependencies']
240+
: ['prepare', 'dependencies'],
225241
);
226242
assert.equal(indexRequests, 0);
227243
assert.equal(checksumRequests, 1);

‎crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/peer-config.mjs‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ const { generateInstallLockfile, installManifest, INSTALL_PNPM_VERSION } = await
3030
pathToFileURL(join(cli, 'generate-install-lockfile.ts')).href
3131
);
3232
const version = JSON.parse(readFileSync(join(cli, 'package.json'), 'utf8')).version;
33+
// Setup installs the pinned pnpm under a content key derived from its integrity:
34+
// short_fingerprint(PINNED_PNPM_INTEGRITY) in crates/vp_setup/src/install.rs (the
35+
// first 32 hex of its SHA-256). Seed the install there so the pinned bootstrap
36+
// reuses it instead of downloading.
37+
const SETUP_PNPM_KEY = '2f9ea5a98bd92b2d1d6694aedb029783';
3338

3439
// Only the pinned pnpm distribution uses the runner's cached registry. Real pnpm
3540
// is necessary to exercise configuration precedence and frozen-lockfile checks.
@@ -201,7 +206,11 @@ function createInstallation(name, settings = []) {
201206
join(installNodeBin, 'node'),
202207
);
203208
}
204-
cpSync(pnpmRoot, join(home, 'package_manager/pnpm', INSTALL_PNPM_VERSION), { recursive: true });
209+
cpSync(
210+
join(pnpmRoot, 'pnpm'),
211+
join(home, 'package_manager/pnpm/.verified-v1', INSTALL_PNPM_VERSION, SETUP_PNPM_KEY, 'pnpm'),
212+
{ recursive: true },
213+
);
205214
return { directory, home, ...user };
206215
}
207216

‎crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots.toml‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,6 @@ name = "installation_progress_interactive"
2828
vp = "global"
2929
skip-platforms = ["windows"]
3030
steps = [{ argv = ["node", "install.mjs", "interactive"], interactions = [
31-
{ expect-milestone = "install:download:ready" },
32-
{ write-key = "enter" },
3331
{ expect-milestone = "install:prepare:ready" },
3432
{ write-key = "enter" },
3533
{ expect-milestone = "install:dependencies:ready" },
@@ -41,8 +39,6 @@ name = "installation_progress_failure"
4139
vp = "global"
4240
skip-platforms = ["windows"]
4341
steps = [{ argv = ["node", "install.mjs", "interactive", "failure"], interactions = [
44-
{ expect-milestone = "install:download:ready" },
45-
{ write-key = "enter" },
4642
{ expect-milestone = "install:prepare:ready" },
4743
{ write-key = "enter" },
4844
{ expect-milestone = "install:dependencies:ready" },

‎crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_failure.md‎

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,6 @@
22

33
## `node install.mjs interactive failure`
44

5-
**→ expect-milestone:** `install:download:ready`
6-
7-
```
8-
Before installation: preserve this output.
9-
info: installing vite-plus@<version>...
10-
⠿ Downloading pnpm <version>... <size> B (<size> B/s)
11-
```
12-
13-
**← write-key:** `enter`
14-
155
**→ expect-milestone:** `install:prepare:ready`
166

177
```

‎crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_interactive.md‎

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,6 @@
22

33
## `node install.mjs interactive`
44

5-
**→ expect-milestone:** `install:download:ready`
6-
7-
```
8-
Before installation: preserve this output.
9-
info: installing vite-plus@<version>...
10-
⠿ Downloading pnpm <version>... <size> B (<size> B/s)
11-
```
12-
13-
**← write-key:** `enter`
14-
155
**→ expect-milestone:** `install:prepare:ready`
166

177
```

‎crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/upgrade-node.mjs‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ const version = JSON.parse(readFileSync(join(cli, 'package.json'), 'utf8')).vers
3636
const pnpmVersion = /export const INSTALL_PNPM_VERSION = '([^']+)';/.exec(
3737
readFileSync(join(cli, 'generate-install-lockfile.ts'), 'utf8'),
3838
)[1];
39+
// Setup installs the pinned pnpm under a content key derived from its integrity:
40+
// short_fingerprint(PINNED_PNPM_INTEGRITY) in crates/vp_setup/src/install.rs (the
41+
// first 32 hex of its SHA-256). Seed the stub there so the pinned bootstrap reuses
42+
// it instead of downloading.
43+
const SETUP_PNPM_KEY = '2f9ea5a98bd92b2d1d6694aedb029783';
3944
const home = resolve('upgrade-home');
4045
const user = resolve('upgrade-user');
4146
const current = join(home, 'current');
@@ -109,7 +114,7 @@ for (const nodeVersion of [installNode, targetNode]) {
109114
mkdirSync(dirname(destination), { recursive: true });
110115
copyFileSync(join(runnerHome, 'js_runtime/node', nodeVersion, nodePath), destination);
111116
}
112-
const pnpm = join(home, 'package_manager/pnpm', pnpmVersion, 'pnpm');
117+
const pnpm = join(home, 'package_manager/pnpm/.verified-v1', pnpmVersion, SETUP_PNPM_KEY, 'pnpm');
113118
write(join(pnpm, 'package.json'), JSON.stringify({
114119
version: pnpmVersion,
115120
engines: { node: incompatible ? `>=${installNode}` : '*' },

0 commit comments

Comments
 (0)