From fffa2f4e95d97d40fb0431b39b8300331a2ffaf8 Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Mon, 9 Nov 2026 19:01:39 +0800 Subject: [PATCH] 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 Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 3 + .../command_self_setup_external/homebrew.mjs | 88 +- .../snapshots.toml | 5 +- ...d_self_setup_homebrew_user_dependencies.md | 2 +- .../fixtures/download_progress/install.mjs | 38 +- .../download_progress/peer-config.mjs | 11 +- .../fixtures/download_progress/snapshots.toml | 4 - .../installation_progress_failure.md | 10 - .../installation_progress_interactive.md | 10 - .../download_progress/upgrade-node.mjs | 7 +- .../install_yarn_corepack_hash/snapshots.toml | 16 +- .../snapshots/install_yarn_corepack_hash.md | 12 +- .../install_yarn_corepack_hash_mismatch.md | 2 +- .../snapshots/run_yarn_corepack_hash.md | 12 +- .../run_yarn_corepack_hash_mismatch.md | 2 +- .../tests/cli_snapshots/redact.rs | 28 + crates/vp_global_cli/Cargo.toml | 1 + .../vp_global_cli/src/commands/env/clean.rs | 19 +- .../vp_global_cli/src/commands/env/current.rs | 13 +- .../vp_global_cli/src/commands/env/doctor.rs | 23 +- .../src/commands/env/lifecycle.rs | 92 +- crates/vp_global_cli/src/commands/env/list.rs | 55 +- .../vp_global_cli/src/commands/env/which.rs | 14 +- crates/vp_global_cli/src/shim/dispatch.rs | 51 +- crates/vp_pm_cli/src/lib.rs | 13 +- crates/vp_pm_cli/src/package_manager.rs | 1062 +++++++++++++---- .../src/package_manager/bootstrap.rs | 33 +- crates/vp_pm_cli/src/request.rs | 34 +- .../tests/package_manager_pin_verification.rs | 365 ++++++ crates/vp_setup/src/install.rs | 36 +- packages/cli/binding/Cargo.toml | 3 + packages/cli/binding/src/cli/mod.rs | 69 +- rfcs/package-manager-detection.md | 16 +- 33 files changed, 1733 insertions(+), 416 deletions(-) create mode 100644 crates/vp_pm_cli/tests/package_manager_pin_verification.rs diff --git a/Cargo.lock b/Cargo.lock index 77dbca72ed..9458520d61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8357,6 +8357,8 @@ dependencies = [ "clap", "console", "cow-utils", + "flate2", + "httpmock", "napi", "napi-build", "napi-derive", @@ -8367,6 +8369,7 @@ dependencies = [ "rustc-hash", "serde", "serde_json", + "tar", "tempfile", "tokio", "tracing", diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/homebrew.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/homebrew.mjs index c0ae24ac9c..51cbbec5ab 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/homebrew.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/homebrew.mjs @@ -34,6 +34,11 @@ const originalEnv = Object.fromEntries( ); const token = 'synthetic-homebrew-token'; const pin = '10.34.7'; +// short_fingerprint(PINNED_PNPM_INTEGRITY) in crates/vp_setup/src/install.rs (the +// first 32 hex of its SHA-256). The bundled pnpm is pinned, so setup verifies its +// download against that integrity; a fake tarball cannot match it, so tests seed +// the stub at the pinned install's content key instead. +const SETUP_PNPM_KEY = '2f9ea5a98bd92b2d1d6694aedb029783'; const lockfile = "lockfileVersion: '9.0'\n"; fs.writeFileSync( path.join(root, 'package.json'), @@ -191,7 +196,47 @@ function keg(version) { return prefix; } -function environment(name, auth = true, seedRuntime = true, seedInstallRuntime = seedRuntime) { +// Write a runnable stub pnpm install under `dataDir`, at both the pinned install's +// content key and the version-directory root (for the unpinned `pn`/`pnx` shim), +// so neither the setup bootstrap nor the shim downloads it. +function seedStubPnpm(dataDir) { + for (const key of [SETUP_PNPM_KEY, null]) { + // A pinned install lives under the hidden verified namespace; the unpinned + // install stays at the version-directory root. + const install = key + ? path.join(dataDir, 'package_manager/pnpm/.verified-v1', pin, key, 'pnpm') + : path.join(dataDir, 'package_manager/pnpm', pin, 'pnpm'); + const bin = path.join(install, 'bin'); + fs.mkdirSync(bin, { recursive: true }); + fs.writeFileSync( + path.join(install, 'package.json'), + JSON.stringify({ + name: 'pnpm', + version: pin, + engines: { node: '>=18.12' }, + bin: { pnpm: 'bin/pnpm.cjs', pnpx: 'bin/pnpx.cjs' }, + }), + ); + for (const tool of ['pnpm', 'pnpx']) { + fs.writeFileSync(path.join(bin, `${tool}.cjs`), pnpmCode); + fs.writeFileSync(path.join(bin, tool), `#!/bin/sh\nexec node "$(dirname "$0")/${tool}.cjs" "$@"\n`, { + mode: 0o755, + }); + } + } +} + +function environment( + name, + auth = true, + seedRuntime = true, + seedInstallRuntime = seedRuntime, + // Default to `auth`: an unauthenticated scenario's failure is at the + // auth-protected pnpm download, so it must download (and 401) rather than reuse + // a seed. Authenticated scenarios seed so setup reuses pnpm instead of failing + // the integrity pin on a fake download. + seedPnpm = auth, +) { const home = path.join(root, name); const vpHome = path.join(home, '.vite-plus'); fs.mkdirSync(home, { recursive: true }); @@ -212,6 +257,7 @@ function environment(name, auth = true, seedRuntime = true, seedInstallRuntime = // Existing installations already have their tool and dependency-install runtimes. fs.writeFileSync(path.join(vpHome, '.vp-owned'), 'vite-plus-owned-directory\n'); } + if (seedPnpm) seedStubPnpm(vpHome); fs.writeFileSync( path.join(home, '.npmrc'), `registry=${url}/registry/\n${auth ? `//127.0.0.1:${server.address().port}/registry/:_authToken=\${NPM_TOKEN}\n` : ''}`, @@ -273,7 +319,7 @@ try { rejectInstall = false; const retry = await Promise.all([run(publicVp, ['--help'], env), run(publicVp, ['--help'], env)]); assert.equal(installs, 2, 'retry must install once across concurrent launches'); - assert.equal(downloads, 1, 'the retry must reuse bootstrapped pnpm'); + assert.equal(downloads, 0, 'setup and retry must reuse the provisioned pnpm, never downloading it'); assert.equal(indexRequests, 0, 'dependency retries must also use the release pin'); assert.ok(retry.every((result) => !result.stderr.includes('manage Node'))); assert.equal(fs.readFileSync(settingsFile, 'utf8'), preferences); @@ -584,7 +630,9 @@ try { } for (const split of [false, true]) { - const fresh = environment(`fresh-${split ? 'split' : 'single'}`, true, false); + // seedPnpm = false: the fresh-root assertions below require an empty start, so + // the stub pnpm is seeded inside the loop, after that check, instead. + const fresh = environment(`fresh-${split ? 'split' : 'single'}`, true, false, false, false); const data = split ? path.join(fresh.HOME, 'data') : fresh.VP_HOME; const cache = split ? path.join(fresh.HOME, 'cache') : path.join(data, 'cache'); const bin = split ? path.join(fresh.HOME, 'bin') : path.join(data, 'bin'); @@ -607,6 +655,11 @@ try { const npmrc = fs.readFileSync(path.join(fresh.HOME, '.npmrc'), 'utf8'); for (let attempt = 0; attempt < 2; attempt++) { for (const directory of roots) assert.ok(!fs.existsSync(directory), directory); + // Seed the stub pnpm now that the empty-start check has passed, so fresh + // setup reuses it instead of downloading a fake tarball that fails the pin. + // Mark the data root vp-owned so setup accepts the pre-seeded directory. + seedStubPnpm(data); + fs.writeFileSync(path.join(data, '.vp-owned'), 'vite-plus-owned-directory\n'); const before = { installs, indexRequests, @@ -642,7 +695,11 @@ try { ['direct', `${url}/registry/`], ['interpolated', '${TEST_REGISTRY}/registry/'], ]) { - const override = environment(`${name}-registry-override`); + // seedPnpm = false: the override must carry the pnpm download to the + // configured registry, so assert the protected request happens instead of + // letting a seeded cache hide a broken override. The fixture's stub tarball + // then fails the integrity pin, so setup stops with a nonzero status. + const override = environment(`${name}-registry-override`, true, true, true, false); const config = path.join(override.HOME, '.npmrc'); fs.writeFileSync( config, @@ -651,7 +708,12 @@ try { .replace(`${url}/registry/`, 'http://127.0.0.1:1/unavailable/'), ); override.NPM_CONFIG_REGISTRY = registry; - await run(publicVp, ['--help'], override); + const before = downloads; + await run(publicVp, ['--help'], override, root, 1); + assert.ok( + downloads > before, + `${name} registry override must carry the pnpm download to the configured registry`, + ); } for (const [name, selector] of [ @@ -670,16 +732,22 @@ try { 'Relative, tilde, and environment-variable selectors reach pnpm as the same absolute user config.', ); - const basic = environment('basic-auth'); + // seedPnpm = false: the Basic-auth credential must carry the pnpm download, so + // assert the protected request happens instead of letting a seeded cache pass + // even when the credential is wrong. The fixture's stub tarball then fails the + // integrity pin, so setup stops with a nonzero status. + const basic = environment('basic-auth', true, true, true, false); const encoded = Buffer.from('fixture:synthetic-password').toString('base64'); fs.writeFileSync( path.join(basic.HOME, '.npmrc'), `registry=${url}/registry/\n//127.0.0.1:${server.address().port}/registry/:_auth=${encoded}\n`, ); authorization = `Basic ${encoded}`; - await run(publicVp, ['--help'], basic); + const beforeBasic = downloads; + await run(publicVp, ['--help'], basic, root, 1); + assert.ok(downloads > beforeBasic, 'basic authentication must carry the protected pnpm download'); authorization = `Bearer ${token}`; - console.log('Registry overrides and basic authentication work with the shared downloader.'); + console.log('Registry overrides and basic authentication reach the shared downloader.'); const unauthenticated = environment('unauthenticated', false); await run(publicVp, ['--help'], unauthenticated, root, 1); @@ -729,7 +797,9 @@ try { assert.ok(registryRequests > requestsBeforeAlias, 'shim arguments must not bypass setup'); corrupt = true; - await run(publicVp, ['--help'], environment('corrupt'), root, 1); + // seedPnpm = false: this scenario needs a real (corrupt) pnpm download to verify + // that a tarball failing verification stops setup. + await run(publicVp, ['--help'], environment('corrupt', true, true, true, false), root, 1); console.log('Missing authentication and corrupted pnpm tarballs stop setup.'); unlock(replacement); } finally { diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots.toml index dce8bb647f..c3865e4cbe 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots.toml @@ -8,7 +8,10 @@ steps = [["node", "verify-refresh.mjs", "homebrew-sources"]] name = "command_self_setup_homebrew_user_dependencies" vp = "global" skip-platforms = ["windows"] -steps = [{ argv = ["node", "homebrew.mjs"], timeout = 120000 }] +# The heaviest external case: it drives ~15 full self-setup scenarios in one +# `node homebrew.mjs` run, so it needs a wider timeout than the 120s default, +# especially under parallel load. +steps = [{ argv = ["node", "homebrew.mjs"], timeout = 300000 }] [[case]] name = "command_self_setup_homebrew_collision" diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_homebrew_user_dependencies.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_homebrew_user_dependencies.md index c9f7712ffc..ab94cdae05 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_homebrew_user_dependencies.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/command_self_setup_external/snapshots/command_self_setup_homebrew_user_dependencies.md @@ -14,7 +14,7 @@ Cached Node, package managers, and global tools run without CLI dependency repai CLI aliases, explicit installers, and unconfigured shims still prepare CLI dependencies. User installations and cleanup stay independent. Relative, tilde, and environment-variable selectors reach pnpm as the same absolute user config. -Registry overrides and basic authentication work with the shared downloader. +Registry overrides and basic authentication reach the shared downloader. After authentication failure, diagnostics, upgrade guidance, and cleanup remain available. Missing authentication and corrupted pnpm tarballs stop setup. ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/install.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/install.mjs index 146b492f24..a43946948b 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/install.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/install.mjs @@ -43,13 +43,12 @@ let runtimeDownloads = 0; let checksumRequests = 0; let indexRequests = 0; -const pnpmArchive = createArchive({ - 'package/package.json': JSON.stringify({ - name: 'pnpm', - version: '10.34.7', - bin: { pnpm: 'bin/pnpm.cjs' }, - }), - 'package/bin/pnpm.cjs': ` +const pnpmManifest = JSON.stringify({ + name: 'pnpm', + version: '10.34.7', + bin: { pnpm: 'bin/pnpm.cjs' }, +}); +const pnpmCjs = ` const fs = require('node:fs'); const assert = require('node:assert/strict'); assert.ok(process.argv.includes('--frozen-lockfile')); @@ -61,9 +60,26 @@ const pnpmArchive = createArchive({ fs.writeFileSync('node_modules/vite-plus/package.json', '{"name":"vite-plus"}'); if (!response.ok) process.exit(17); }); - `, + `; +const pnpmArchive = createArchive({ + 'package/package.json': pnpmManifest, + 'package/bin/pnpm.cjs': pnpmCjs, }); +// The bundled pnpm is pinned, so setup verifies its download against the bundled +// integrity. A fake tarball cannot match that pin, so seed the stub at the pinned +// install's content key instead; the bootstrap reuses it and skips the download. +// Key = short_fingerprint(PINNED_PNPM_INTEGRITY) in crates/vp_setup/src/install.rs +// (the first 32 hex of its SHA-256). +const SETUP_PNPM_KEY = '2f9ea5a98bd92b2d1d6694aedb029783'; +const seededPnpm = join(home, 'package_manager/pnpm/.verified-v1/10.34.7', SETUP_PNPM_KEY, 'pnpm'); +mkdirSync(join(seededPnpm, 'bin'), { recursive: true }); +writeFileSync(join(seededPnpm, 'package.json'), pnpmManifest); +writeFileSync(join(seededPnpm, 'bin/pnpm.cjs'), pnpmCjs); +for (const shim of ['pnpm', 'pnpm.cmd', 'pnpm.ps1']) { + writeFileSync(join(seededPnpm, 'bin', shim), 'unused'); +} + // The request is made after the CLI draws each phase. Hold its response until // the runner captures the milestone, without sleeps or polling terminal text. const phases = []; @@ -182,7 +198,7 @@ try { assert.equal(existsSync(marker), false); assert.equal(existsSync(receipt), resolutionRetry); assert.equal(existsSync(resolutionFile), false); - assert.deepEqual(phases, ['download', 'prepare', 'dependencies']); + assert.deepEqual(phases, ['prepare', 'dependencies']); console.log( resolutionRetry ? 'A failed resolution-file write leaves dependencies installed and setup incomplete.' @@ -220,8 +236,8 @@ try { assert.deepEqual( phases, dependencyRetry - ? ['download', 'prepare', 'dependencies', 'dependencies'] - : ['download', 'prepare', 'dependencies'], + ? ['prepare', 'dependencies', 'dependencies'] + : ['prepare', 'dependencies'], ); assert.equal(indexRequests, 0); assert.equal(checksumRequests, 1); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/peer-config.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/peer-config.mjs index 9617c0d345..3cea97d630 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/peer-config.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/peer-config.mjs @@ -30,6 +30,11 @@ const { generateInstallLockfile, installManifest, INSTALL_PNPM_VERSION } = await pathToFileURL(join(cli, 'generate-install-lockfile.ts')).href ); const version = JSON.parse(readFileSync(join(cli, 'package.json'), 'utf8')).version; +// Setup installs the pinned pnpm under a content key derived from its integrity: +// short_fingerprint(PINNED_PNPM_INTEGRITY) in crates/vp_setup/src/install.rs (the +// first 32 hex of its SHA-256). Seed the install there so the pinned bootstrap +// reuses it instead of downloading. +const SETUP_PNPM_KEY = '2f9ea5a98bd92b2d1d6694aedb029783'; // Only the pinned pnpm distribution uses the runner's cached registry. Real pnpm // is necessary to exercise configuration precedence and frozen-lockfile checks. @@ -201,7 +206,11 @@ function createInstallation(name, settings = []) { join(installNodeBin, 'node'), ); } - cpSync(pnpmRoot, join(home, 'package_manager/pnpm', INSTALL_PNPM_VERSION), { recursive: true }); + cpSync( + join(pnpmRoot, 'pnpm'), + join(home, 'package_manager/pnpm/.verified-v1', INSTALL_PNPM_VERSION, SETUP_PNPM_KEY, 'pnpm'), + { recursive: true }, + ); return { directory, home, ...user }; } diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots.toml index 3dd12fecf0..9ef600a939 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots.toml @@ -28,8 +28,6 @@ name = "installation_progress_interactive" vp = "global" skip-platforms = ["windows"] steps = [{ argv = ["node", "install.mjs", "interactive"], interactions = [ - { expect-milestone = "install:download:ready" }, - { write-key = "enter" }, { expect-milestone = "install:prepare:ready" }, { write-key = "enter" }, { expect-milestone = "install:dependencies:ready" }, @@ -41,8 +39,6 @@ name = "installation_progress_failure" vp = "global" skip-platforms = ["windows"] steps = [{ argv = ["node", "install.mjs", "interactive", "failure"], interactions = [ - { expect-milestone = "install:download:ready" }, - { write-key = "enter" }, { expect-milestone = "install:prepare:ready" }, { write-key = "enter" }, { expect-milestone = "install:dependencies:ready" }, diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_failure.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_failure.md index 8ad305ea43..dfa44e72e5 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_failure.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_failure.md @@ -2,16 +2,6 @@ ## `node install.mjs interactive failure` -**→ expect-milestone:** `install:download:ready` - -``` -Before installation: preserve this output. -info: installing vite-plus@... -⠿ Downloading pnpm ... B ( B/s) -``` - -**← write-key:** `enter` - **→ expect-milestone:** `install:prepare:ready` ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_interactive.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_interactive.md index ee36674471..56ee596dc6 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_interactive.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/snapshots/installation_progress_interactive.md @@ -2,16 +2,6 @@ ## `node install.mjs interactive` -**→ expect-milestone:** `install:download:ready` - -``` -Before installation: preserve this output. -info: installing vite-plus@... -⠿ Downloading pnpm ... B ( B/s) -``` - -**← write-key:** `enter` - **→ expect-milestone:** `install:prepare:ready` ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/upgrade-node.mjs b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/upgrade-node.mjs index c1ba0016e7..2c6b94ddad 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/upgrade-node.mjs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/download_progress/upgrade-node.mjs @@ -36,6 +36,11 @@ const version = JSON.parse(readFileSync(join(cli, 'package.json'), 'utf8')).vers const pnpmVersion = /export const INSTALL_PNPM_VERSION = '([^']+)';/.exec( readFileSync(join(cli, 'generate-install-lockfile.ts'), 'utf8'), )[1]; +// Setup installs the pinned pnpm under a content key derived from its integrity: +// short_fingerprint(PINNED_PNPM_INTEGRITY) in crates/vp_setup/src/install.rs (the +// first 32 hex of its SHA-256). Seed the stub there so the pinned bootstrap reuses +// it instead of downloading. +const SETUP_PNPM_KEY = '2f9ea5a98bd92b2d1d6694aedb029783'; const home = resolve('upgrade-home'); const user = resolve('upgrade-user'); const current = join(home, 'current'); @@ -109,7 +114,7 @@ for (const nodeVersion of [installNode, targetNode]) { mkdirSync(dirname(destination), { recursive: true }); copyFileSync(join(runnerHome, 'js_runtime/node', nodeVersion, nodePath), destination); } -const pnpm = join(home, 'package_manager/pnpm', pnpmVersion, 'pnpm'); +const pnpm = join(home, 'package_manager/pnpm/.verified-v1', pnpmVersion, SETUP_PNPM_KEY, 'pnpm'); write(join(pnpm, 'package.json'), JSON.stringify({ version: pnpmVersion, engines: { node: incompatible ? `>=${installNode}` : '*' }, diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml index a29b06792f..57b127744d 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots.toml @@ -3,10 +3,10 @@ name = "install_yarn_corepack_hash" vp = "global" env = { YARN_ENABLE_TELEMETRY = "0" } steps = [ - { argv = ["vpt", "rm", "-rf", "$VP_HOME/package_manager/yarn/4.17.1", "$VP_HOME/package_manager/yarn/4.17.1.lock"], comment = "Ensure the Corepack-pinned Yarn version is not cached", snapshot = false }, - { argv = ["vpt", "stat-file", "$VP_HOME/package_manager/yarn/4.17.1", "--assert", "missing"], comment = "Yarn 4.17.1 is not in the cache" }, + { argv = ["vpt", "rm", "-rf", "$VP_HOME/package_manager/yarn/4.17.1", "$VP_HOME/package_manager/yarn/.verified-v1/4.17.1", "$VP_HOME/package_manager/yarn/4.17.1.lock"], comment = "Ensure the Corepack-pinned Yarn version is not cached", snapshot = false }, + { argv = ["vpt", "stat-file", "$VP_HOME/package_manager/yarn/.verified-v1/4.17.1", "--assert", "missing"], comment = "Yarn 4.17.1 is not in the cache" }, { argv = ["vp", "install"], comment = "A first install accepts the hash that Corepack wrote", timeout = 120000 }, - { argv = ["vpt", "stat-file", "$VP_HOME/package_manager/yarn/4.17.1/yarn/bin/yarn.js", "--assert", "file"], comment = "The cache holds the Yarn CLI that vp verified" }, + { argv = ["vpt", "stat-file", "$VP_HOME/package_manager/yarn/.verified-v1/4.17.1/3156b767c366db0f2c765c40a66f4ace/yarn/bin/yarn.js", "--assert", "file"], comment = "The cache holds the Yarn CLI that vp verified, under the content key of the pinned hash (short_fingerprint of the packageManager hash)" }, ] [[case]] @@ -15,7 +15,7 @@ vp = "global" comment = "Every environment entry point must preserve and verify the same Corepack integrity suffix without repeating the expensive cache setup in separate cases." env = { YARN_ENABLE_TELEMETRY = "0" } steps = [ - { argv = ["vpt", "rm", "-rf", "$VP_HOME/package_manager/yarn/4.17.1", "$VP_HOME/package_manager/yarn/4.17.1.lock"], comment = "Ensure the Corepack-pinned Yarn version is not cached", snapshot = false }, + { argv = ["vpt", "rm", "-rf", "$VP_HOME/package_manager/yarn/4.17.1", "$VP_HOME/package_manager/yarn/.verified-v1/4.17.1", "$VP_HOME/package_manager/yarn/4.17.1.lock"], comment = "Ensure the Corepack-pinned Yarn version is not cached", snapshot = false }, { argv = ["vp", "install"], comment = "Cache the verified Yarn CLI", timeout = 120000, snapshot = false }, { argv = ["vp", "env", "install", "pm"], comment = "The explicit environment install accepts the verified project hash" }, { argv = ["vpt", "replace-file-content", "package.json", "b7ad4697", "b7ad4698"], comment = "Change the pin to a hash that the cached CLI does not match", snapshot = false }, @@ -30,10 +30,10 @@ name = "run_yarn_corepack_hash" vp = "global" env = { YARN_ENABLE_TELEMETRY = "0" } steps = [ - { argv = ["vpt", "rm", "-rf", "$VP_HOME/package_manager/yarn/4.17.1", "$VP_HOME/package_manager/yarn/4.17.1.lock"], comment = "Ensure the Corepack-pinned Yarn version is not cached", snapshot = false }, - { argv = ["vpt", "stat-file", "$VP_HOME/package_manager/yarn/4.17.1", "--assert", "missing"], comment = "Yarn 4.17.1 is not in the cache" }, + { argv = ["vpt", "rm", "-rf", "$VP_HOME/package_manager/yarn/4.17.1", "$VP_HOME/package_manager/yarn/.verified-v1/4.17.1", "$VP_HOME/package_manager/yarn/4.17.1.lock"], comment = "Ensure the Corepack-pinned Yarn version is not cached", snapshot = false }, + { argv = ["vpt", "stat-file", "$VP_HOME/package_manager/yarn/.verified-v1/4.17.1", "--assert", "missing"], comment = "Yarn 4.17.1 is not in the cache" }, { argv = ["vp", "run", "smoke"], comment = "A first vp run accepts the hash and runs the task", timeout = 120000 }, - { argv = ["vpt", "stat-file", "$VP_HOME/package_manager/yarn/4.17.1/yarn/bin/yarn.js", "--assert", "file"], comment = "vp run wrote the verified Yarn CLI to the cache" }, + { argv = ["vpt", "stat-file", "$VP_HOME/package_manager/yarn/.verified-v1/4.17.1/3156b767c366db0f2c765c40a66f4ace/yarn/bin/yarn.js", "--assert", "file"], comment = "vp run wrote the verified Yarn CLI to the cache, under the content key of the pinned hash" }, { argv = ["vp", "run", "smoke"], comment = "A second vp run uses the cached Yarn CLI" }, ] @@ -42,7 +42,7 @@ name = "run_yarn_corepack_hash_mismatch" vp = "global" env = { YARN_ENABLE_TELEMETRY = "0" } steps = [ - { argv = ["vpt", "rm", "-rf", "$VP_HOME/package_manager/yarn/4.17.1", "$VP_HOME/package_manager/yarn/4.17.1.lock"], comment = "Ensure the Corepack-pinned Yarn version is not cached", snapshot = false }, + { argv = ["vpt", "rm", "-rf", "$VP_HOME/package_manager/yarn/4.17.1", "$VP_HOME/package_manager/yarn/.verified-v1/4.17.1", "$VP_HOME/package_manager/yarn/4.17.1.lock"], comment = "Ensure the Corepack-pinned Yarn version is not cached", snapshot = false }, { argv = ["vp", "install"], comment = "Cache the verified Yarn CLI", timeout = 120000, snapshot = false }, { argv = ["vpt", "replace-file-content", "package.json", "b7ad4697", "b7ad4698"], comment = "Change the pin to a hash that the cached CLI does not match", snapshot = false }, { argv = ["vp", "run", "smoke"], comment = "vp run reports the integrity failure. It does not start the task", continue-on-failure = true }, diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash.md index 234c1708aa..e1f0aa0022 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash.md @@ -1,16 +1,16 @@ # install_yarn_corepack_hash -## `vpt rm -rf $VP_HOME/package_manager/yarn/4.17.1 $VP_HOME/package_manager/yarn/4.17.1.lock` +## `vpt rm -rf $VP_HOME/package_manager/yarn/4.17.1 $VP_HOME/package_manager/yarn/.verified-v1/4.17.1 $VP_HOME/package_manager/yarn/4.17.1.lock` Ensure the Corepack-pinned Yarn version is not cached -## `vpt stat-file $VP_HOME/package_manager/yarn/4.17.1 --assert missing` +## `vpt stat-file $VP_HOME/package_manager/yarn/.verified-v1/4.17.1 --assert missing` Yarn 4.17.1 is not in the cache ``` -/.vite-plus/package_manager/yarn/: missing +/.vite-plus/package_manager/yarn/.verified-v1/: missing ``` ## `vp install` @@ -30,10 +30,10 @@ VITE+ - The Unified Toolchain for the Web ➤ YN0000: · Done in ``` -## `vpt stat-file $VP_HOME/package_manager/yarn/4.17.1/yarn/bin/yarn.js --assert file` +## `vpt stat-file $VP_HOME/package_manager/yarn/.verified-v1/4.17.1/3156b767c366db0f2c765c40a66f4ace/yarn/bin/yarn.js --assert file` -The cache holds the Yarn CLI that vp verified +The cache holds the Yarn CLI that vp verified, under the content key of the pinned hash (short_fingerprint of the packageManager hash) ``` -/.vite-plus/package_manager/yarn//yarn/bin/yarn.js: file +/.vite-plus/package_manager/yarn/.verified-v1///yarn/bin/yarn.js: file ``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash_mismatch.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash_mismatch.md index f9eab7a2bb..d83be57de1 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash_mismatch.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/install_yarn_corepack_hash_mismatch.md @@ -2,7 +2,7 @@ Every environment entry point must preserve and verify the same Corepack integrity suffix without repeating the expensive cache setup in separate cases. -## `vpt rm -rf $VP_HOME/package_manager/yarn/4.17.1 $VP_HOME/package_manager/yarn/4.17.1.lock` +## `vpt rm -rf $VP_HOME/package_manager/yarn/4.17.1 $VP_HOME/package_manager/yarn/.verified-v1/4.17.1 $VP_HOME/package_manager/yarn/4.17.1.lock` Ensure the Corepack-pinned Yarn version is not cached diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/run_yarn_corepack_hash.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/run_yarn_corepack_hash.md index f897ffa188..bf664573c7 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/run_yarn_corepack_hash.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/run_yarn_corepack_hash.md @@ -1,16 +1,16 @@ # run_yarn_corepack_hash -## `vpt rm -rf $VP_HOME/package_manager/yarn/4.17.1 $VP_HOME/package_manager/yarn/4.17.1.lock` +## `vpt rm -rf $VP_HOME/package_manager/yarn/4.17.1 $VP_HOME/package_manager/yarn/.verified-v1/4.17.1 $VP_HOME/package_manager/yarn/4.17.1.lock` Ensure the Corepack-pinned Yarn version is not cached -## `vpt stat-file $VP_HOME/package_manager/yarn/4.17.1 --assert missing` +## `vpt stat-file $VP_HOME/package_manager/yarn/.verified-v1/4.17.1 --assert missing` Yarn 4.17.1 is not in the cache ``` -/.vite-plus/package_manager/yarn/: missing +/.vite-plus/package_manager/yarn/.verified-v1/: missing ``` ## `vp run smoke` @@ -24,12 +24,12 @@ $ vpt print yarn hash accepted ⊘ cache disabled yarn hash accepted ``` -## `vpt stat-file $VP_HOME/package_manager/yarn/4.17.1/yarn/bin/yarn.js --assert file` +## `vpt stat-file $VP_HOME/package_manager/yarn/.verified-v1/4.17.1/3156b767c366db0f2c765c40a66f4ace/yarn/bin/yarn.js --assert file` -vp run wrote the verified Yarn CLI to the cache +vp run wrote the verified Yarn CLI to the cache, under the content key of the pinned hash ``` -/.vite-plus/package_manager/yarn//yarn/bin/yarn.js: file +/.vite-plus/package_manager/yarn/.verified-v1///yarn/bin/yarn.js: file ``` ## `vp run smoke` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/run_yarn_corepack_hash_mismatch.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/run_yarn_corepack_hash_mismatch.md index e6aed071f0..74934623df 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/run_yarn_corepack_hash_mismatch.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/install_yarn_corepack_hash/snapshots/run_yarn_corepack_hash_mismatch.md @@ -1,6 +1,6 @@ # run_yarn_corepack_hash_mismatch -## `vpt rm -rf $VP_HOME/package_manager/yarn/4.17.1 $VP_HOME/package_manager/yarn/4.17.1.lock` +## `vpt rm -rf $VP_HOME/package_manager/yarn/4.17.1 $VP_HOME/package_manager/yarn/.verified-v1/4.17.1 $VP_HOME/package_manager/yarn/4.17.1.lock` Ensure the Corepack-pinned Yarn version is not cached diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs b/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs index 453fec079d..078e793eab 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/redact.rs @@ -131,6 +131,29 @@ static WINDOWS_MANAGED_PM_BIN_RE: LazyLock = LazyLock::new(|| { regex::Regex::new(r#"(/.vite-plus/package_manager/[^"\r\n]+/bin/[A-Za-z0-9._-]+)\.cmd\b"#) .unwrap() }); +// A pinned managed install is addressed by a content key under the verified +// namespace: `.verified-v1//<32-hex>`. The fingerprint folds in the +// verified pin (and, for a platform-binary manager, the registry origin, whose +// ephemeral port varies per run), so normalize the key segment to . An +// unpinned install has no verified namespace and no key segment. The version +// before the key is already masked to by the path redactions, but also +// accept a bare semver so ordering stays robust. +static PM_INSTALL_KEY_RE: LazyLock = LazyLock::new(|| { + regex::Regex::new( + r"(/package_manager/[^/]+/\.verified-v1/(?:|\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/)[0-9a-f]{32}(/)", + ) + .unwrap() +}); +// The verified namespace places the version after `.verified-v1/`, where the +// tool-name version mask does not reach it. Mask it to so a pinned +// install path reads like the unpinned one. In output the version is followed by +// `/` (the key segment) or `:` (a stat result). +static PM_VERIFIED_VERSION_RE: LazyLock = LazyLock::new(|| { + regex::Regex::new( + r"(/package_manager/[^/]+/\.verified-v1/)\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?([/:])", + ) + .unwrap() +}); static COMMAND_NOT_FOUND_RE: LazyLock = LazyLock::new(|| { regex::Regex::new( r"(Command execution failed: )(?:No such file or directory \(os error 2\)|program not found)", @@ -566,6 +589,11 @@ pub fn redact_output( output = WINDOWS_MANAGED_NODE_BIN_RE.replace_all(&output, "${1}/bin/${2}").into_owned(); output = WINDOWS_MANAGED_NODE_PATH_RE.replace_all(&output, "${1}/bin${2}").into_owned(); output = WINDOWS_MANAGED_PM_BIN_RE.replace_all(&output, "${1}").into_owned(); + // Normalize the managed install's content-key directory segment to . + output = PM_INSTALL_KEY_RE.replace_all(&output, "${1}${2}").into_owned(); + // The verified namespace's version segment is not reached by the tool-name + // version mask; normalize it to here. + output = PM_VERIFIED_VERSION_RE.replace_all(&output, "${1}${2}").into_owned(); output = COMMAND_NOT_FOUND_RE.replace_all(&output, "${1}program not found").into_owned(); // Redact UUIDs to "" diff --git a/crates/vp_global_cli/Cargo.toml b/crates/vp_global_cli/Cargo.toml index c5ddcdb06a..7ed1875bf1 100644 --- a/crates/vp_global_cli/Cargo.toml +++ b/crates/vp_global_cli/Cargo.toml @@ -55,6 +55,7 @@ uuid = { workspace = true, features = ["v4"] } [dev-dependencies] httpmock = { workspace = true } serial_test = { workspace = true } +tar = { workspace = true } temp-env = { workspace = true } vp_shared = { workspace = true, features = ["test-utils"] } diff --git a/crates/vp_global_cli/src/commands/env/clean.rs b/crates/vp_global_cli/src/commands/env/clean.rs index dd2204c44b..c71604aed6 100644 --- a/crates/vp_global_cli/src/commands/env/clean.rs +++ b/crates/vp_global_cli/src/commands/env/clean.rs @@ -9,7 +9,14 @@ use vp_pm_cli::{PackageManagerType, resolve_package_manager_version_for_cwd}; use vp_shared::output; use vt_path::{AbsolutePath, AbsolutePathBuf}; -use super::{config, list::list_installed_versions, package_manager, spec::EnvScope}; +use super::{ + config, + list::{ + list_installed_versions, merged_package_manager_versions, package_manager_version_dirs, + }, + package_manager, + spec::EnvScope, +}; use crate::error::Error; /// Execute the clean command. @@ -112,11 +119,17 @@ async fn clean_package_managers( .find(|(protected_kind, _)| protected_kind == kind) .map(|(_, versions)| versions.as_slice()) .unwrap_or_default(); - for version in list_installed_versions(&family) { + for version in merged_package_manager_versions(&family) { if protected_versions.contains(&version) { continue; } - if remove_dir_all_if_exists(&family.join(version)).await? { + // Remove the unpinned and pinned locations for the version, counting the + // version once. + let mut version_removed = false; + for dir in package_manager_version_dirs(&family, &version) { + version_removed |= remove_dir_all_if_exists(&dir).await?; + } + if version_removed { removed += 1; } } diff --git a/crates/vp_global_cli/src/commands/env/current.rs b/crates/vp_global_cli/src/commands/env/current.rs index 3cd6b06e1a..16d0d83b75 100644 --- a/crates/vp_global_cli/src/commands/env/current.rs +++ b/crates/vp_global_cli/src/commands/env/current.rs @@ -1,7 +1,7 @@ use std::{collections::BTreeMap, process::ExitStatus}; use serde::Serialize; -use vp_pm_cli::{package_manager_bin_path, package_manager_install_dir}; +use vp_pm_cli::{package_manager_bin_path, resolve_package_manager_install_dir}; use vt_path::AbsolutePathBuf; use super::{ @@ -289,9 +289,14 @@ async fn resolve_package_manager_info( let source = resolution.source.to_string(); let source_path = resolution.source_path.map(|path| path.as_path().display().to_string()); let project_root = resolution.project_root.map(|path| path.as_path().display().to_string()); - let Some(install_dir) = package_manager_install_dir(package_manager_type, &version) else { - return Ok(None); - }; + // Report the directory the shim selects for this pin (an unpinned manager uses + // the version-directory root). `installed` below reflects whether the directory + // actually holds the binaries. + let install_dir = resolve_package_manager_install_dir( + package_manager_type, + &version, + resolution.hash.as_deref(), + )?; let bin_paths = package_manager_type .bin_names() .iter() diff --git a/crates/vp_global_cli/src/commands/env/doctor.rs b/crates/vp_global_cli/src/commands/env/doctor.rs index dc684c1a0a..638d2766eb 100644 --- a/crates/vp_global_cli/src/commands/env/doctor.rs +++ b/crates/vp_global_cli/src/commands/env/doctor.rs @@ -6,7 +6,7 @@ use std::{ }; use console::style; -use vp_pm_cli::{package_manager_bin_path, package_manager_install_dir}; +use vp_pm_cli::{package_manager_bin_path, resolve_package_manager_install_dir}; use vp_shared::{env_vars, output}; use vt_path::{AbsolutePathBuf, current_dir}; @@ -463,13 +463,20 @@ async fn check_package_manager_resolution( .bright() .to_string(), ); - let installed = - package_manager_install_dir(resolution.package_manager_type, &resolution.version) - .is_some_and(|directory| { - resolution.package_manager_type.bin_names().iter().all(|name| { - package_manager_bin_path(&directory, name).as_path().exists() - }) - }); + // Report the directory the shim selects for this pin, so the check + // matches the executable the shim runs rather than another cached install. + let installed = resolve_package_manager_install_dir( + resolution.package_manager_type, + &resolution.version, + resolution.hash.as_deref(), + ) + .is_ok_and(|directory| { + resolution + .package_manager_type + .bin_names() + .iter() + .all(|name| package_manager_bin_path(&directory, name).as_path().exists()) + }); if installed { print_check(&style(output::CHECK).green().to_string(), "PM binaries", "installed"); } else { diff --git a/crates/vp_global_cli/src/commands/env/lifecycle.rs b/crates/vp_global_cli/src/commands/env/lifecycle.rs index e0b91eef7b..3de8fd6f35 100644 --- a/crates/vp_global_cli/src/commands/env/lifecycle.rs +++ b/crates/vp_global_cli/src/commands/env/lifecycle.rs @@ -1,10 +1,12 @@ -use std::process::ExitStatus; +use std::{path::PathBuf, process::ExitStatus}; use vp_pm_cli::{download_package_manager_for_cwd, resolve_package_manager_version_for_cwd}; use vt_path::AbsolutePathBuf; use super::{ - config, package_manager, + config, + list::package_manager_version_dirs, + package_manager, spec::{EnvScope, EnvSpecs}, }; use crate::{cli::exit_status, error::Error}; @@ -111,27 +113,99 @@ pub(crate) async fn uninstall(specs: Vec) -> Result { }; let home = vp_shared::EnvConfig::get().dirs.data.clone(); - let mut targets = Vec::new(); + // Each target is one logical install with the directories that hold it. A Node + // runtime has one; a package-manager version has an unpinned directory and a + // verified-namespace directory, both from `package_manager_version_dirs`. + let mut targets: Vec<(String, Vec)> = Vec::new(); if let Some(version) = node { targets.push(( format!("Node.js v{version}"), - home.join("js_runtime").join("node").join(version), + vec![home.join("js_runtime").join("node").join(version).into_path_buf()], )); } if let Some((kind, version)) = package_manager { + let family = home.join("package_manager").join(kind.to_string()); targets.push(( format!("{kind} v{version}"), - home.join("package_manager").join(kind.to_string()).join(version), + package_manager_version_dirs(family.as_path(), &version), )); } - for (label, target) in &targets { - if !target.as_path().exists() { + for (label, dirs) in &targets { + if !dirs.iter().any(|dir| dir.exists()) { return Err(Error::Other(format!("{label} is not installed").into())); } } - for (label, target) in targets { - tokio::fs::remove_dir_all(target.as_path()).await?; + for (label, dirs) in targets { + for dir in dirs { + if dir.exists() { + tokio::fs::remove_dir_all(&dir).await?; + } + } vp_shared::output::print_stdout_line(format_args!("Uninstalled {label}")); } Ok(ExitStatus::default()) } + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::*; + + /// Create a package-manager install directory so the version directory exists. + fn seed(install_dir: &std::path::Path) { + std::fs::create_dir_all(install_dir.join("bin")).unwrap(); + std::fs::write(install_dir.join("bin/pnpm.cjs"), "cli").unwrap(); + } + + async fn run_uninstall(vp_home: &std::path::Path) -> Result { + vp_shared::EnvConfig::with_vars_async( + [(vp_shared::env_vars::VP_HOME, vp_home.as_os_str())], + |_| async { uninstall(vec!["pnpm@10.34.7".to_string()]).await }, + ) + .await + } + + #[tokio::test] + async fn uninstall_removes_a_pinned_only_install() { + let temp = TempDir::new().unwrap(); + let verified_version = temp.path().join("package_manager/pnpm/.verified-v1/10.34.7"); + seed(&verified_version.join("0123456789abcdef0123456789abcdef/pnpm")); + + assert!(run_uninstall(temp.path()).await.unwrap().success()); + assert!(!verified_version.exists(), "the pinned install must be removed"); + } + + #[tokio::test] + async fn uninstall_removes_an_unpinned_only_install() { + let temp = TempDir::new().unwrap(); + let unpinned_version = temp.path().join("package_manager/pnpm/10.34.7"); + seed(&unpinned_version.join("pnpm")); + + assert!(run_uninstall(temp.path()).await.unwrap().success()); + assert!(!unpinned_version.exists(), "the unpinned install must be removed"); + } + + #[tokio::test] + async fn uninstall_removes_unpinned_and_every_pinned_key() { + let temp = TempDir::new().unwrap(); + let unpinned_version = temp.path().join("package_manager/pnpm/10.34.7"); + let verified_version = temp.path().join("package_manager/pnpm/.verified-v1/10.34.7"); + seed(&unpinned_version.join("pnpm")); + seed(&verified_version.join("00000000000000000000000000000000/pnpm")); + seed(&verified_version.join("11111111111111111111111111111111/pnpm")); + + assert!(run_uninstall(temp.path()).await.unwrap().success()); + assert!(!unpinned_version.exists(), "the unpinned install must be removed"); + assert!(!verified_version.exists(), "every pinned key must be removed"); + } + + #[tokio::test] + async fn uninstall_reports_an_absent_manager() { + let temp = TempDir::new().unwrap(); + assert!( + run_uninstall(temp.path()).await.is_err(), + "uninstall must fail when neither install layout exists" + ); + } +} diff --git a/crates/vp_global_cli/src/commands/env/list.rs b/crates/vp_global_cli/src/commands/env/list.rs index b346841f53..5554f80b41 100644 --- a/crates/vp_global_cli/src/commands/env/list.rs +++ b/crates/vp_global_cli/src/commands/env/list.rs @@ -1,8 +1,14 @@ -use std::{collections::BTreeMap, process::ExitStatus}; +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, + process::ExitStatus, +}; use console::style; use serde::Serialize; -use vp_pm_cli::{PackageManagerType, package_manager_bin_path, package_manager_install_dir}; +use vp_pm_cli::{ + PackageManagerType, VERIFIED_NAMESPACE, installed_package_manager_dir, package_manager_bin_path, +}; use vt_path::AbsolutePathBuf; use super::{config, package_manager, spec::EnvScope}; @@ -148,19 +154,40 @@ pub(super) fn list_complete_package_manager_versions( home: &AbsolutePathBuf, package_manager: PackageManagerType, ) -> Vec { - list_installed_versions( - home.join("package_manager").join(package_manager.to_string()).as_path(), - ) - .into_iter() - .filter(|version| { - package_manager_install_dir(package_manager, version).is_some_and(|directory| { - package_manager - .bin_names() - .iter() - .all(|name| package_manager_bin_path(&directory, name).as_path().exists()) + let family = home.join("package_manager").join(package_manager.to_string()); + merged_package_manager_versions(family.as_path()) + .into_iter() + .filter(|version| { + installed_package_manager_dir(package_manager, version).is_some_and(|directory| { + package_manager + .bin_names() + .iter() + .all(|name| package_manager_bin_path(&directory, name).as_path().exists()) + }) }) - }) - .collect() + .collect() +} + +/// Version directory names with any cached install for one package manager, +/// merging the unpinned version-directory root with the verified namespace. +/// `family_dir` is `/package_manager/`. Shared by `list` and `clean`. +pub(super) fn merged_package_manager_versions(family_dir: &Path) -> Vec { + let mut versions = list_installed_versions(family_dir); + for version in list_installed_versions(&family_dir.join(VERIFIED_NAMESPACE)) { + if !versions.contains(&version) { + versions.push(version); + } + } + versions.sort_by_cached_key(|version| js_semver::Version::parse(version).ok()); + versions +} + +/// The directories that back one package-manager version: the unpinned install at +/// the version-directory root and the pinned installs under the verified +/// namespace. `family_dir` is `/package_manager/`. Shared by `clean` +/// and `uninstall` so the layout lives in one place. +pub(super) fn package_manager_version_dirs(family_dir: &Path, version: &str) -> Vec { + vec![family_dir.join(version), family_dir.join(VERIFIED_NAMESPACE).join(version)] } fn print_section(title: &str, versions: &[InstalledVersionJson], node: bool) { diff --git a/crates/vp_global_cli/src/commands/env/which.rs b/crates/vp_global_cli/src/commands/env/which.rs index 95e6dc1d22..ffb7b2cd68 100644 --- a/crates/vp_global_cli/src/commands/env/which.rs +++ b/crates/vp_global_cli/src/commands/env/which.rs @@ -11,7 +11,7 @@ use std::process::ExitStatus; use chrono::Local; use console::style; use vp_pm_cli::{ - PackageManagerType, package_manager_bin_path, package_manager_install_dir, + PackageManagerType, package_manager_bin_path, resolve_package_manager_install_dir, resolve_package_manager_version_for_cwd, }; use vp_shared::output; @@ -160,13 +160,14 @@ async fn execute_package_manager_tool( return Ok(None); }; let resolution = package_manager::resolve_shim_for(cwd, expected_type).await?; - let (version, source) = match &resolution { + let (version, source, expected_hash) = match &resolution { Some(resolution) => ( resolution.version.to_string(), resolution.source_path.as_ref().map_or_else( || resolution.source.to_string(), |path| path.as_path().display().to_string(), ), + resolution.hash.clone(), ), None if expected_type == PackageManagerType::Npm => return Ok(None), None => ( @@ -174,12 +175,15 @@ async fn execute_package_manager_tool( .await? .to_string(), "registry fallback".into(), + None, ), }; - let Some(install_dir) = package_manager_install_dir(expected_type, &version) else { - return Ok(None); - }; + // The directory the shim selects for this pin (an unpinned manager uses the + // version-directory root); the existence check below reports whether it is + // actually present. + let install_dir = + resolve_package_manager_install_dir(expected_type, &version, expected_hash.as_deref())?; let bin_name = expected_type.bin_name_for_tool(tool); let tool_path = package_manager_bin_path(&install_dir, bin_name); diff --git a/crates/vp_global_cli/src/shim/dispatch.rs b/crates/vp_global_cli/src/shim/dispatch.rs index 97555c7642..09eeac3db1 100644 --- a/crates/vp_global_cli/src/shim/dispatch.rs +++ b/crates/vp_global_cli/src/shim/dispatch.rs @@ -1404,7 +1404,9 @@ mod tests { } #[tokio::test] - async fn test_hash_pinned_modern_yarn_rechecks_cached_cli() { + async fn test_hash_pinned_modern_yarn_rejects_mismatched_download() { + use httpmock::prelude::*; + let temp = TempDir::new().unwrap(); let vp_home = AbsolutePathBuf::new(temp.path().join("vp-home")).unwrap(); let cwd = AbsolutePathBuf::new(temp.path().join("project")).unwrap(); @@ -1417,17 +1419,23 @@ mod tests { ) .unwrap(); - // VP_HOME pins to the root, so the cached install lands here. - let bin_dir = - vp_home.join("package_manager").join("yarn").join("4.17.1").join("yarn").join("bin"); - std::fs::create_dir_all(&bin_dir).unwrap(); - std::fs::write(bin_dir.join("yarn"), "shim").unwrap(); - std::fs::write(bin_dir.join("yarn.cmd"), "shim").unwrap(); - std::fs::write(bin_dir.join("yarn.ps1"), "shim").unwrap(); - std::fs::write(bin_dir.join("yarn.js"), "corrupt").unwrap(); + // A Yarn 2+ pin covers the CLI binary, verified when it is downloaded. Serve + // the `@yarnpkg/cli-dist` tarball from a local registry so the check runs + // offline; its CLI bytes cannot match the all-zeros pin, so the download must + // be rejected and the error must reach the shim caller. + let server = MockServer::start(); + let tarball = cli_dist_tgz(b"console.log('yarn');\n"); + server.mock(|when, then| { + when.method(GET).path("/@yarnpkg/cli-dist/-/cli-dist-4.17.1.tgz"); + then.status(200).body(tarball); + }); + let registry = server.base_url(); vp_shared::EnvConfig::with_vars_async( - [(env_vars::VP_HOME, vp_home.as_path())], + [ + (env_vars::VP_HOME, vp_home.as_path().as_os_str()), + ("NPM_CONFIG_REGISTRY", std::ffi::OsStr::new(®istry)), + ], |_| async { let result = resolve_package_manager_tool(&cwd, "yarn").await; assert!( @@ -1435,13 +1443,34 @@ mod tests { result, Err(Error::Install(vp_error::Error::PackageManagerHashMismatch { .. })) ), - "the global Yarn shim must reject a corrupted pinned cache: {result:?}" + "the global Yarn shim must reject a pinned download whose CLI fails the pin: {result:?}" ); }, ) .await; } + /// Build an `@yarnpkg/cli-dist` style gzip tarball holding `package/bin/yarn.js`. + fn cli_dist_tgz(yarn_js: &[u8]) -> Vec { + let mut builder = tar::Builder::new(Vec::new()); + let mut header = tar::Header::new_gnu(); + header.set_size(yarn_js.len() as u64); + header.set_mode(0o755); + header.set_cksum(); + builder + .append_data(&mut header, "package/bin/yarn.js", std::io::Cursor::new(yarn_js)) + .unwrap(); + let tar_data = builder.into_inner().unwrap(); + let mut gz = Vec::new(); + { + let mut encoder = + flate2::write::GzEncoder::new(&mut gz, flate2::Compression::default()); + std::io::Write::write_all(&mut encoder, &tar_data).unwrap(); + encoder.finish().unwrap(); + } + gz + } + #[tokio::test] async fn test_resolve_with_cache_bypasses_stale_lts_after_dev_engines_is_added() { let temp = TempDir::new().unwrap(); diff --git a/crates/vp_pm_cli/src/lib.rs b/crates/vp_pm_cli/src/lib.rs index e23c8711d4..509b6979bc 100644 --- a/crates/vp_pm_cli/src/lib.rs +++ b/crates/vp_pm_cli/src/lib.rs @@ -29,14 +29,15 @@ pub use error::Error; pub use lifecycle_env::LifecycleEnvContext; pub use package_manager::{ EnvironmentPackageManagerResolution, PackageManager, PackageManagerBuilder, - PackageManagerResolution, PackageManagerSource, PackageManagerType, bootstrap_pnpm, - download_package_manager, download_package_manager_for_cwd, ensure_package_manager_bin, - fetch_package_manager_versions, fetch_package_manager_versions_for_cwd, - get_package_manager_type_and_version, is_package_manager_dist_tag, + PackageManagerResolution, PackageManagerSource, PackageManagerType, VERIFIED_NAMESPACE, + bootstrap_pnpm, download_package_manager, download_package_manager_for_cwd, + ensure_package_manager_bin, fetch_package_manager_versions, + fetch_package_manager_versions_for_cwd, get_package_manager_type_and_version, + install_cache_key, installed_package_manager_dir, is_package_manager_dist_tag, npm_version_from_package_json, package_manager_bin_path, package_manager_install_dir, resolve_environment_package_manager, resolve_environment_package_manager_spec, - resolve_package_manager_from_package_json, resolve_package_manager_version, - resolve_package_manager_version_for_cwd, + resolve_package_manager_from_package_json, resolve_package_manager_install_dir, + resolve_package_manager_version, resolve_package_manager_version_for_cwd, }; pub use request::HttpClient; pub use resolution::{ diff --git a/crates/vp_pm_cli/src/package_manager.rs b/crates/vp_pm_cli/src/package_manager.rs index 28f6e7ecd0..cdd9e2651c 100644 --- a/crates/vp_pm_cli/src/package_manager.rs +++ b/crates/vp_pm_cli/src/package_manager.rs @@ -22,9 +22,7 @@ use vt_workspace::{WorkspaceFile, WorkspaceRoot, find_workspace_root}; use crate::{ config::NpmConfig, - request::{ - HttpClient, download_and_extract_tgz_with_hash_and_config, is_not_found, verify_file_hash, - }, + request::{HttpClient, download_and_extract_tgz_with_hash_and_config, is_not_found}, shim, }; @@ -120,6 +118,19 @@ impl PackageManagerType { matches!(self, Self::Yarn) && is_yarn_berry(version) } + /// Whether this version's executable comes from a platform package, so a + /// `packageManager` hash covers the main npm tarball rather than the binary + /// that runs. The binary is authenticated against its registry's integrity, + /// so a cached copy is bound to that registry's origin as well as the pin. + #[must_use] + pub fn uses_platform_binary(self, version: &str) -> bool { + match self { + Self::Bun => true, + Self::Pnpm => Version::parse(version).is_ok_and(|version| version.major >= 12), + _ => false, + } + } + #[must_use] pub const fn bin_names(self) -> &'static [&'static str] { match self { @@ -131,9 +142,168 @@ impl PackageManagerType { } } -/// Name of the file that records the pin vp verified when it installed a -/// package manager, relative to the version directory. -const VERIFIED_PIN_RECORD: &str = ".verified-pin"; +/// A short, opaque fingerprint (first 32 hex of SHA-256) for a cache key. +fn short_fingerprint(input: &str) -> Str { + Str::from(&hex::encode(Sha256::digest(input.as_bytes()))[..32]) +} + +/// The cache-key directory segment that binds a *verified* install to what it was +/// verified against, so a different pin or origin resolves to a different, +/// immutable directory instead of sharing one. +/// +/// - No pin: `None`. An unpinned install has no integrity reference, so it stays +/// on the shared version directory with no key segment. +/// - Pinned tarball manager: keyed by the pin, which covers the executed files. +/// - Platform-binary manager (bun, pnpm >= 12): keyed by pin and origin, because +/// the pin covers the main tarball, not the executed platform binary. +#[must_use] +pub fn install_cache_key( + expected_hash: Option<&str>, + platform_origin: Option<&str>, +) -> Option { + match (expected_hash, platform_origin) { + (None, _) => None, + (Some(pin), None) => Some(short_fingerprint(pin)), + (Some(pin), Some(origin)) => Some(short_fingerprint(&format!("{pin}\n{origin}"))), + } +} + +/// Resolve the cache key for a manager. Returns `None` for an unpinned install. +/// Only a platform-binary manager folds the registry origin into the key, so the +/// npm config is loaded lazily in that branch and never on the common warm path. +fn resolve_cache_key( + package_manager_type: PackageManagerType, + version: &str, + expected_hash: Option<&str>, +) -> Result, Error> { + let platform_origin = if package_manager_type.uses_platform_binary(version) { + let platform_package = match package_manager_type { + PackageManagerType::Bun => get_bun_platform_package_name()?, + PackageManagerType::Pnpm => get_pnpm_platform_package_name()?.to_string(), + other => { + return Err(Error::UnsupportedPackageManager( + format!("{other} has no platform package").into(), + )); + } + }; + let npm_config = NpmConfig::load(); + Some(origin_fingerprint(&npm_config.registry_for_package(&platform_package))) + } else { + None + }; + Ok(install_cache_key(expected_hash, platform_origin.as_deref())) +} + +/// The directory for one package-manager version's unpinned install. Pinned +/// installs live in the verified namespace instead; see +/// [`package_manager_verified_version_dir`]. +fn package_manager_version_dir( + package_manager_type: PackageManagerType, + version: &str, +) -> AbsolutePathBuf { + let bin_name = package_manager_type.to_string(); + vp_shared::EnvConfig::get().dirs.data.join("package_manager").join(&bin_name).join(version) +} + +/// The hidden namespace that holds verified, pinned installs, kept out of the +/// version directories that unpinned installs and earlier layouts write to. Only +/// a download verified against its pin creates a directory here, so a reuse check +/// can trust a complete entry without re-reading the pin. The `-v1` suffix lets +/// the layout change later without reusing an older directory. +pub const VERIFIED_NAMESPACE: &str = ".verified-v1"; + +/// The directory holding every pinned install of one package-manager version, one +/// immutable subdirectory per [`install_cache_key`], under the verified namespace. +fn package_manager_verified_version_dir( + package_manager_type: PackageManagerType, + version: &str, +) -> AbsolutePathBuf { + package_manager_verified_namespace_dir(package_manager_type).join(version) +} + +/// The hidden directory holding a package manager's pinned installs, one +/// subdirectory per version, keyed by [`VERIFIED_NAMESPACE`]. +fn package_manager_verified_namespace_dir( + package_manager_type: PackageManagerType, +) -> AbsolutePathBuf { + let bin_name = package_manager_type.to_string(); + vp_shared::EnvConfig::get() + .dirs + .data + .join("package_manager") + .join(&bin_name) + .join(VERIFIED_NAMESPACE) +} + +/// Find a complete cached install of a version for callers that only report +/// whether it is installed and hold no pin to select a key. Prefers the unpinned +/// install at the version-directory root, then the first complete pinned install. +#[must_use] +pub fn installed_package_manager_dir( + package_manager_type: PackageManagerType, + version: &str, +) -> Option { + let bin_name = package_manager_type.to_string(); + // An unpinned install lives at the version-directory root. + let unpinned = package_manager_version_dir(package_manager_type, version).join(&bin_name); + if is_package_manager_install_complete(&unpinned, &bin_name).unwrap_or(false) { + return Some(unpinned); + } + // A pinned install lives under its content-key subdirectory in the verified + // namespace. + let verified_dir = package_manager_verified_version_dir(package_manager_type, version); + for entry in fs::read_dir(verified_dir.as_path()).ok()?.flatten() { + let Some(install_dir) = + AbsolutePathBuf::new(entry.path()).map(|key_dir| key_dir.join(&bin_name)) + else { + continue; + }; + if is_package_manager_install_complete(&install_dir, &bin_name).unwrap_or(false) { + return Some(install_dir); + } + } + None +} + +/// An opaque, irreversible fingerprint of a registry origin, for the cache key. +/// +/// The raw origin can embed credentials (`https://user:pass@host`), so the cache +/// key folds in this hash rather than the URL. It is deterministic, so the same +/// origin fingerprints the same way for a later reuse check, and it keeps scoped +/// registries and paths distinct because the whole URL feeds the hash. +fn origin_fingerprint(origin: &str) -> String { + format!("sha256.{}", hex::encode(Sha256::digest(origin.as_bytes()))) +} + +/// Publish a freshly downloaded install into `install_dir`. Call this holding the +/// install lock, with the install staged at `target_dir_tmp/`. +/// +/// A complete install is never replaced: a pinned directory binds its verified +/// content, and an unpinned one is already current, so if another process +/// published it first, reuse it and drop the staged copy. Only a partial leftover +/// from an interrupted install, which no caller uses, is cleared before the staged +/// copy is moved into place. Replacing only `install_dir` leaves sibling installs +/// of the same version (other keys, or the unpinned install) untouched. +async fn publish_install( + install_dir: AbsolutePathBuf, + target_dir_tmp: &Path, + bin_name: &str, + package_name: Str, + version: Str, +) -> Result<(AbsolutePathBuf, Str, Str), Error> { + if is_package_manager_install_complete(&install_dir, bin_name)? { + tracing::debug!("{bin_name} install already complete after lock, reuse it"); + return Ok((install_dir, package_name, version)); + } + // Create the install's parent now, so a failed install never leaves an empty + // version directory behind. The staged copy is moved in as the final step. + if let Some(parent) = install_dir.parent() { + tokio::fs::create_dir_all(parent).await?; + } + remove_dir_all_force(&install_dir).await?; + tokio::fs::rename(target_dir_tmp.join(bin_name), install_dir.as_path()).await?; + Ok((install_dir, package_name, version)) +} /// Path of the Yarn CLI inside `@yarnpkg/cli-dist`, relative to the package root. /// @@ -658,15 +828,42 @@ pub async fn resolve_environment_package_manager( Ok(Some(resolution)) } -/// Return the managed install directory for a package manager version. +/// Return the managed install directory for a package manager version. A pinned +/// install (`cache_key = Some`) lives under its content-key subdirectory; an +/// unpinned install (`None`) lives at the version-directory root. #[must_use] pub fn package_manager_install_dir( package_manager_type: PackageManagerType, version: &str, -) -> Option { + cache_key: Option<&str>, +) -> AbsolutePathBuf { let bin_name = package_manager_type.to_string(); - let data_dir = &vp_shared::EnvConfig::get().dirs.data; - Some(data_dir.join("package_manager").join(&bin_name).join(version).join(&bin_name)) + match cache_key { + // A pinned install lives under its content key in the verified namespace, + // outside the version directory that unpinned installs and earlier layouts + // write to. + Some(key) => package_manager_verified_version_dir(package_manager_type, version) + .join(key) + .join(&bin_name), + // An unpinned install stays at the version-directory root. + None => package_manager_version_dir(package_manager_type, version).join(&bin_name), + } +} + +/// The install directory the shim selects for a pin, without downloading. +/// +/// Mirrors the key selection in [`ensure_package_manager_bin`] so a report of the +/// executable (`vp env which` / `current` / `doctor`) names the directory the shim +/// runs, not a different cached install under the same version. A pinned request +/// resolves to its verified, key-addressed directory; an unpinned one to the +/// version-directory root. +pub fn resolve_package_manager_install_dir( + package_manager_type: PackageManagerType, + version: &str, + expected_hash: Option<&str>, +) -> Result { + let cache_key = resolve_cache_key(package_manager_type, version, expected_hash)?; + Ok(package_manager_install_dir(package_manager_type, version, cache_key.as_deref())) } /// Return the executable shim path for a package manager binary inside an install directory. @@ -683,11 +880,13 @@ pub fn package_manager_bin_path(install_dir: &AbsolutePath, bin_name: &str) -> A /// Return a managed package-manager binary, downloading its release when needed. /// -/// This function returns the cached path when the shim already exists. A pin -/// that covers a file inside the install is the exception: it goes through -/// [`verify_cached_cli_hash`], which compares the pin against the record vp -/// wrote at install time. The rule stays here, so the shim hot path holds no -/// package-manager specifics. +/// The install directory is key-addressed by the content it was verified against, +/// so a complete directory holds exactly the files that match `expected_hash` (and +/// the platform origin, for bun and native pnpm). The shim passes the project's +/// declared `packageManager` hash, so a request with a different pin resolves to a +/// different directory and falls through to `download_package_manager` instead of +/// running cached content it was not verified against. The key rule stays in +/// `resolve_cache_key`, so the shim hot path holds no package-manager specifics. pub async fn ensure_package_manager_bin( package_manager_type: PackageManagerType, version_or_latest: &str, @@ -695,18 +894,16 @@ pub async fn ensure_package_manager_bin( bin_name: &str, ) -> Result { let version = resolve_package_manager_version(package_manager_type, version_or_latest).await?; - - let verifies_cached_cli = - expected_hash.is_some() && package_manager_type.uses_cli_binary_hash(&version); - if !verifies_cached_cli - && let Some(install_dir) = package_manager_install_dir(package_manager_type, &version) - { - let bin_path = package_manager_bin_path(&install_dir, bin_name); - if bin_path.as_path().exists() { - #[cfg(windows)] - ensure_native_windows_bins(package_manager_type, &install_dir).await?; - return Ok(package_manager_bin_path(&install_dir, bin_name)); - } + let install_dir = + resolve_package_manager_install_dir(package_manager_type, &version, expected_hash)?; + // The directory is keyed by the pin (and platform origin), so only a download + // for this exact key publishes into it; an unpinned one is the version's + // current install. Check only the launcher the caller runs: requiring every + // sibling would drop the Windows `.cmd` fallback when the `.ps1` is missing. + if package_manager_bin_path(&install_dir, bin_name).as_path().is_file() { + #[cfg(windows)] + ensure_native_windows_bins(package_manager_type, &install_dir).await?; + return Ok(package_manager_bin_path(&install_dir, bin_name)); } let (install_dir, _, _) = @@ -1334,11 +1531,13 @@ fn find_cached_package_manager_version( if best.as_ref().is_some_and(|b| version <= *b) { continue; } - // Only consider completed installs, using the same completeness check as - // the download fast-path so range resolution never selects a partially - // written install (e.g. plain bin present but `.cmd`/`.ps1` missing). - let install_dir = versions_dir.join(name).join(&bin_name); - if !is_package_manager_install_complete(&install_dir, &bin_name)? { + // A range request carries no pin, so it can only reuse an unpinned + // install; a pinned-only cached version lives in the verified namespace and + // would force a fresh download. Use the same completeness check as the + // download fast path so resolution never selects a partially written + // install (e.g. plain bin present but `.cmd`/`.ps1` missing). + let unpinned = package_manager_install_dir(package_manager_type, name, None); + if !is_package_manager_install_complete(&unpinned, &bin_name).unwrap_or(false) { continue; } best = Some(version); @@ -1509,13 +1708,11 @@ async fn download_package_manager_inner( package_name = "@yarnpkg/cli-dist".into(); } - let config = vp_shared::EnvConfig::get(); - let data_dir = &config.dirs.data; let bin_name = package_manager_type.to_string(); // Bun pins cover the main tarball, so verify its platform binary separately. if matches!(package_manager_type, PackageManagerType::Bun) { - return download_bun_package_manager(&version, data_dir, expected_hash, npm_config).await; + return download_bun_package_manager(&version, expected_hash, npm_config).await; } // pnpm >= 12 is a native binary; download the @pnpm/exe.* platform package @@ -1523,37 +1720,38 @@ async fn download_package_manager_inner( // A declared hash names the main tarball and is verified against it; the // platform tarball is verified against the registry's `dist.integrity`. if matches!(package_manager_type, PackageManagerType::Pnpm) && parsed_version.major >= 12 { - return download_pnpm_native_package_manager(&version, data_dir, expected_hash, npm_config) - .await; + return download_pnpm_native_package_manager(&version, expected_hash, npm_config).await; } let tgz_url = tarball_url .map(Str::from) .unwrap_or_else(|| npm_config.package_tgz_url(&package_name, &version)); - // /package_manager/pnpm/10.0.0 - let target_dir = data_dir.join("package_manager").join(&bin_name).join(&version); - let install_dir = target_dir.join(&bin_name); - - // If all shims already exist, return the target directory - // /package_manager/pnpm/10.0.0/pnpm/bin/(pnpm|pnpm.cmd|pnpm.ps1) + // A pinned install is key-addressed so a different pin resolves to a + // different, immutable directory; an unpinned install stays on the version + // directory. The generic path serves managers whose pin covers the executed + // files, so it has no platform origin. + // /package_manager/pnpm/10.0.0/[{key}/]pnpm/bin/(pnpm|pnpm.cmd|pnpm.ps1) + let cache_key = install_cache_key(expected_hash, None); + let version_dir = package_manager_version_dir(package_manager_type, &version); + let install_dir = + package_manager_install_dir(package_manager_type, &version, cache_key.as_deref()); + + // A complete install holds the content for this pin (or the current unpinned + // install); reuse it. if is_package_manager_install_complete(&install_dir, &bin_name)? { - verify_cached_cli_hash( - package_manager_type, - &target_dir, - &install_dir, - expected_hash, - &version, - ) - .await?; return Ok((install_dir, package_name, version)); } + // Stage the download in a temp directory beside the version directory, not + // inside it. A reader that scans the version directory for key subdirectories + // then never sees the half-written staging copy. Only create the manager + // directory now, so a failed install leaves no empty version directory; the + // version directory is created when the staged copy is published. // /package_manager/pnpm/{tmp_name} - // Use tempfile::TempDir for robust temporary directory creation - let parent_dir = target_dir.parent().unwrap(); - tokio::fs::create_dir_all(parent_dir).await?; + let manager_dir = version_dir.parent().expect("a version directory has a parent"); + tokio::fs::create_dir_all(manager_dir).await?; // Keep the TempDir guard alive so a failure path cleans up the temp dir. - let tmp_dir = tempfile::tempdir_in(parent_dir)?; + let tmp_dir = tempfile::tempdir_in(manager_dir)?; let target_dir_tmp = tmp_dir.path().to_path_buf(); let download_message = format!("Downloading {package_manager_type} v{version}..."); @@ -1601,85 +1799,16 @@ async fn download_package_manager_inner( // This prevents DirectoryNotEmpty error when multiple processes/threads // try to install the same package manager version concurrently. // The lock is automatically skipped on NFS filesystems where locking is unreliable. - let lock_path = parent_dir.join(format!("{version}.lock")); + let lock_path = manager_dir.join(format!("{version}.lock")); tracing::debug!("Acquire lock file: {:?}", lock_path); let lock_file = open_lock_file(lock_path.as_path())?; // Acquire exclusive lock (blocks until available) lock_file.lock()?; tracing::debug!("Lock acquired: {:?}", lock_path); - // Check again after acquiring the lock, in case another thread completed - // the installation while we were preparing it in the temporary directory. - if is_package_manager_install_complete(&install_dir, &bin_name)? { - tracing::debug!("install already complete after lock acquisition, skip rename"); - verify_cached_cli_hash( - package_manager_type, - &target_dir, - &install_dir, - expected_hash, - &version, - ) - .await?; - return Ok((install_dir, package_name, version)); - } - - // Record the pin this install was verified against. The record moves into - // place with the install, so a later command can trust the cache after one - // string comparison. - if let Some(expected_hash) = expected_hash - && is_modern_yarn - { - tokio::fs::write(target_dir_tmp.join(VERIFIED_PIN_RECORD), expected_hash).await?; - } - - // rename $target_dir_tmp to $target_dir - tracing::debug!("Rename {:?} to {:?}", target_dir_tmp, target_dir); - remove_dir_all_force(&target_dir).await?; - tokio::fs::rename(&target_dir_tmp, &target_dir).await?; - - Ok((install_dir, package_name, version)) -} - -/// Verify a cached CLI against a pin that covers it. -/// -/// vp records the verified pin when installing Yarn 2+. A matching record avoids -/// rehashing the cached CLI. If the record is missing or the pin changes, vp -/// hashes the CLI and updates the record. -/// -/// A matching record does not detect a CLI modified after installation. The -/// record sits beside the file, so a writer that can replace one can replace -/// the other. Write access to the managed install directory is the trust boundary. -/// -/// Only Yarn 2+ pins cover a retained CLI file. Other pins cover tarballs that -/// vp discards after extraction and are not rechecked here. -async fn verify_cached_cli_hash( - package_manager_type: PackageManagerType, - target_dir: &AbsolutePath, - install_dir: &AbsolutePath, - expected_hash: Option<&str>, - version: &str, -) -> Result<(), Error> { - let Some(expected_hash) = expected_hash else { - return Ok(()); - }; - if !package_manager_type.uses_cli_binary_hash(version) { - return Ok(()); - } - - let record_path = target_dir.join(VERIFIED_PIN_RECORD); - if let Ok(recorded_pin) = tokio::fs::read_to_string(&record_path).await - && recorded_pin.trim() == expected_hash - { - tracing::debug!("cached {package_manager_type} matches the recorded pin"); - return Ok(()); - } - - verify_file_hash(install_dir.join(YARN_CLI_ENTRY), expected_hash) - .await - .map_err(|error| name_hashed_artifact(error, package_manager_type, version))?; - // Best effort: a read-only cache still verifies, it just hashes every time. - let _ = tokio::fs::write(&record_path, expected_hash).await; - Ok(()) + // Publish the staged install, or reuse the directory if another process won + // the race and filled it first. + publish_install(install_dir, &target_dir_tmp, &bin_name, package_name, version).await } /// Name the artifact that a `packageManager` hash covers in an integrity @@ -1761,18 +1890,29 @@ fn bun_requires_baseline() -> bool { /// Layout: `/package_manager/bun/{version}/bun/bin/bun.native` async fn download_bun_package_manager( version: &Str, - home_dir: &AbsolutePath, expected_hash: Option<&str>, npm_config: &NpmConfig, ) -> Result<(AbsolutePathBuf, Str, Str), Error> { let package_name: Str = "bun".into(); - // /package_manager/bun/{version} - let target_dir = home_dir.join("package_manager").join("bun").join(version.as_str()); - let install_dir = target_dir.join("bun"); - - // If shims already exist, return early (same completeness check as the cache - // and the tgz download path) + // The executed binary comes from the platform package, so bind cache reuse + // to the registry that serves it, not only to the main-tarball pin. Store a + // fingerprint of the origin, never the raw URL, which can carry credentials. + let platform_package_name = get_bun_platform_package_name()?; + let platform_origin = + origin_fingerprint(&npm_config.registry_for_package(&platform_package_name)); + + // Key-address a pinned install by the pin and the platform origin, so a + // different pin or origin resolves to a different, immutable directory. An + // unpinned install has no out-of-band anchor and stays on the version dir. + // /package_manager/bun/{version}/[{key}/]bun/bin/bun.native + let cache_key = install_cache_key(expected_hash, Some(platform_origin.as_str())); + let version_dir = package_manager_version_dir(PackageManagerType::Bun, version); + let install_dir = + package_manager_install_dir(PackageManagerType::Bun, version, cache_key.as_deref()); + + // A complete install holds the content for this pin and origin (or the current + // unpinned install); reuse it. if is_package_manager_install_complete(&install_dir, "bun")? { return Ok((install_dir, package_name, version.clone())); } @@ -1794,20 +1934,22 @@ async fn download_bun_package_manager( .map_err(|error| name_hashed_artifact(error, PackageManagerType::Bun, version))?; } - // Temporary by design: select a CPU-compatible package for new downloads without replacing existing installs. - let platform_package_name = get_bun_platform_package_name()?; // The declared hash never covers the platform tarball, so verify it // against the registry's `dist.integrity` for the platform package. let platform_hash = fetch_platform_integrity("bun", &platform_package_name, version, npm_config).await?; - let parent_dir = target_dir.parent().unwrap(); - tokio::fs::create_dir_all(parent_dir).await?; + // Stage the download in a temp directory beside the version directory, not + // inside it, so a reader scanning key subdirectories never sees the staging + // copy. Only create the manager directory now, so a failed install leaves no + // empty version directory; the version directory is created at publish time. + let manager_dir = version_dir.parent().expect("a version directory has a parent"); + tokio::fs::create_dir_all(manager_dir).await?; // Download the platform-specific package directly let platform_tgz_url = npm_config.package_tgz_url(&platform_package_name, version); // Keep the TempDir guard alive so a failure path cleans up the temp dir. - let tmp_dir = tempfile::tempdir_in(parent_dir)?; + let tmp_dir = tempfile::tempdir_in(manager_dir)?; let target_dir_tmp = tmp_dir.path().to_path_buf(); let download_message = format!("Downloading bun v{version}..."); @@ -1870,23 +2012,13 @@ async fn download_bun_package_manager( create_shim_files(PackageManagerType::Bun, AbsolutePath::new(&tmp_bin_dir).unwrap()).await?; // Acquire lock for atomic rename - let lock_path = parent_dir.join(format!("{version}.lock")); + let lock_path = manager_dir.join(format!("{version}.lock")); tracing::debug!("Acquire lock file: {:?}", lock_path); let lock_file = open_lock_file(lock_path.as_path())?; lock_file.lock()?; tracing::debug!("Lock acquired: {:?}", lock_path); - if is_package_manager_install_complete(&install_dir, "bun")? { - tracing::debug!("bun install already complete after lock acquisition, skip rename"); - return Ok((install_dir, package_name, version.clone())); - } - - // Rename temp dir to final location - tracing::debug!("Rename {:?} to {:?}", target_dir_tmp, target_dir); - remove_dir_all_force(&target_dir).await?; - tokio::fs::rename(&target_dir_tmp, &target_dir).await?; - - Ok((install_dir, package_name, version.clone())) + publish_install(install_dir, &target_dir_tmp, "bun", package_name, version.clone()).await } /// Platform-specific `@pnpm/exe.{os}-{arch}` package name for pnpm >= 12. @@ -1967,19 +2099,29 @@ async fn fetch_platform_integrity( /// Layout: `/package_manager/pnpm/{version}/pnpm/bin/pnpm.native` async fn download_pnpm_native_package_manager( version: &Str, - home_dir: &AbsolutePath, expected_hash: Option<&str>, npm_config: &NpmConfig, ) -> Result<(AbsolutePathBuf, Str, Str), Error> { let package_name: Str = "pnpm".into(); let platform_package_name = get_pnpm_platform_package_name()?; - // /package_manager/pnpm/{version} - let target_dir = home_dir.join("package_manager").join("pnpm").join(version.as_str()); - let install_dir = target_dir.join("pnpm"); - - // If shims already exist, return early (same completeness check as the cache - // and the tgz download path) + // The executed binary comes from the platform package, so bind cache reuse + // to the registry that serves it, not only to the main-tarball pin. Store a + // fingerprint of the origin, never the raw URL, which can carry credentials. + let platform_origin = + origin_fingerprint(&npm_config.registry_for_package(platform_package_name)); + + // Key-address a pinned install by the pin and the platform origin, so a + // different pin or origin resolves to a different, immutable directory. An + // unpinned install has no out-of-band anchor and stays on the version dir. + // /package_manager/pnpm/{version}/[{key}/]pnpm/bin/pnpm.native + let cache_key = install_cache_key(expected_hash, Some(platform_origin.as_str())); + let version_dir = package_manager_version_dir(PackageManagerType::Pnpm, version); + let install_dir = + package_manager_install_dir(PackageManagerType::Pnpm, version, cache_key.as_deref()); + + // A complete install holds the content for this pin and origin (or the current + // unpinned install); reuse it. if is_package_manager_install_complete(&install_dir, "pnpm")? { return Ok((install_dir, package_name, version.clone())); } @@ -2008,13 +2150,17 @@ async fn download_pnpm_native_package_manager( let platform_hash = fetch_platform_integrity("pnpm", platform_package_name, version, npm_config).await?; - let parent_dir = target_dir.parent().unwrap(); - tokio::fs::create_dir_all(parent_dir).await?; + // Stage the download in a temp directory beside the version directory, not + // inside it, so a reader scanning key subdirectories never sees the staging + // copy. Only create the manager directory now, so a failed install leaves no + // empty version directory; the version directory is created at publish time. + let manager_dir = version_dir.parent().expect("a version directory has a parent"); + tokio::fs::create_dir_all(manager_dir).await?; // Download the platform-specific package directly let platform_tgz_url = npm_config.package_tgz_url(platform_package_name, version); // Keep the TempDir guard alive so a failure path cleans up the temp dir. - let tmp_dir = tempfile::tempdir_in(parent_dir)?; + let tmp_dir = tempfile::tempdir_in(manager_dir)?; let target_dir_tmp = tmp_dir.path().to_path_buf(); let download_message = format!("Downloading pnpm v{version}..."); @@ -2074,23 +2220,13 @@ async fn download_pnpm_native_package_manager( create_pnpm_native_shim_files(AbsolutePath::new(&tmp_bin_dir).unwrap()).await?; // Acquire lock for atomic rename - let lock_path = parent_dir.join(format!("{version}.lock")); + let lock_path = manager_dir.join(format!("{version}.lock")); tracing::debug!("Acquire lock file: {:?}", lock_path); let lock_file = open_lock_file(lock_path.as_path())?; lock_file.lock()?; tracing::debug!("Lock acquired: {:?}", lock_path); - if is_package_manager_install_complete(&install_dir, "pnpm")? { - tracing::debug!("pnpm install already complete after lock acquisition, skip rename"); - return Ok((install_dir, package_name, version.clone())); - } - - // Rename temp dir to final location - tracing::debug!("Rename {:?} to {:?}", target_dir_tmp, target_dir); - remove_dir_all_force(&target_dir).await?; - tokio::fs::rename(&target_dir_tmp, &target_dir).await?; - - Ok((install_dir, package_name, version.clone())) + publish_install(install_dir, &target_dir_tmp, "pnpm", package_name, version.clone()).await } /// Create shims for pnpm's native binary (pnpm >= 12): `pnpm` execs it @@ -2378,6 +2514,42 @@ mod tests { archive.into_inner().unwrap().finish().unwrap() } + /// Mount the three requests a native (platform-binary) manager makes on + /// `server`: the main tarball, the platform package metadata (its + /// `dist.integrity`), and the platform tarball. + fn mount_platform_registry( + server: &httpmock::MockServer, + package_manager_type: PackageManagerType, + version: &str, + main_tgz: Vec, + platform_tgz: Vec, + platform_integrity: &str, + ) { + use httpmock::prelude::*; + let bin = package_manager_type.to_string(); + let (platform_package, prefix) = match package_manager_type { + PackageManagerType::Bun => (get_bun_platform_package_name().unwrap(), "@oven/"), + PackageManagerType::Pnpm => { + (get_pnpm_platform_package_name().unwrap().to_string(), "@pnpm/") + } + other => panic!("{other} is not a platform-binary manager"), + }; + let basename = platform_package.strip_prefix(prefix).unwrap(); + server.mock(|when, then| { + when.method(GET).path(format!("/{bin}/-/{bin}-{version}.tgz")); + then.status(200).body(main_tgz); + }); + server.mock(|when, then| { + when.method(GET).path(format!("/{platform_package}/{version}")); + then.status(200) + .json_body(serde_json::json!({"dist": {"integrity": platform_integrity}})); + }); + server.mock(|when, then| { + when.method(GET).path(format!("/{platform_package}/-/{basename}-{version}.tgz")); + then.status(200).body(platform_tgz); + }); + } + /// A JavaScript CLI that prints `version`, standing in for a package manager entry point. fn version_cli(version: &str) -> Vec { format!("console.log('{version}');\n").into_bytes() @@ -2639,7 +2811,8 @@ mod tests { } /// Create a fake managed package manager install under - /// `/package_manager////bin/`. + /// `/package_manager////bin/`, the unpinned install + /// layout the detection paths request. fn write_pm_install(data_dir: &AbsolutePath, name: &str, version: &str, state: InstallState) { let bin_dir = data_dir.join("package_manager").join(name).join(version).join(name).join("bin"); @@ -4332,14 +4505,15 @@ mod tests { (env_vars::NPM_CONFIG_REGISTRY, std::ffi::OsStr::new(&server.base_url())), ], |_| async { - let install_dir = package_manager_install_dir(kind, version).unwrap(); - let target_dir = install_dir.parent().unwrap(); + let install_dir = package_manager_install_dir(kind, version, None); let mut install = Box::pin(download_package_manager(kind, version, None)); let mut incomplete = Vec::new(); // Inspect at async suspension points, as a lock-free cache reader could. + // The install directory is renamed into place atomically, so if it + // exists at all its shims must be complete. std::future::poll_fn(|cx| { let result = install.as_mut().poll(cx); - if target_dir.as_path().exists() { + if install_dir.as_path().exists() { for name in kind.bin_names() { for extension in ["", ".cmd", ".ps1"] { let path = @@ -4429,67 +4603,92 @@ mod tests { } #[tokio::test] - async fn test_download_modern_yarn_verifies_corepack_binary_hash() { + async fn native_pnpm_cache_from_a_different_registry_is_not_reused_for_the_same_pin() { use httpmock::prelude::*; - use sha2::{Digest, Sha512}; + use sha2::Sha512; + + let main_tgz = create_tgz(&[( + "package/package.json", + br#"{"name":"pnpm","version":"12.0.0-beta.0"}"#, + )]); + let main_pin = format!("sha512.{}", hex::encode(Sha512::digest(&main_tgz))); + let binary_path = if cfg!(windows) { "package/pnpm.exe" } else { "package/pnpm" }; + let genuine_binary = b"genuine pnpm binary".to_vec(); + let other_binary = b"other pnpm binary".to_vec(); + let genuine_platform = create_tgz(&[(binary_path, genuine_binary.as_slice())]); + let other_platform = create_tgz(&[(binary_path, other_binary.as_slice())]); + let genuine_integrity = format!( + "sha512-{}", + base64_simd::STANDARD.encode_to_string(Sha512::digest(&genuine_platform)) + ); + let other_integrity = format!( + "sha512-{}", + base64_simd::STANDARD.encode_to_string(Sha512::digest(&other_platform)) + ); + let other_registry = MockServer::start(); + mount_platform_registry( + &other_registry, + PackageManagerType::Pnpm, + "12.0.0-beta.0", + main_tgz.clone(), + other_platform, + &other_integrity, + ); + let trusted = MockServer::start(); + mount_platform_registry( + &trusted, + PackageManagerType::Pnpm, + "12.0.0-beta.0", + main_tgz, + genuine_platform, + &genuine_integrity, + ); let vp_home = create_temp_dir(); - let server = MockServer::start(); - let yarn_js = b"#!/usr/bin/env node\nconsole.log('mock yarn');\n"; - let mock_tgz = create_yarn_package_tgz(yarn_js, None); - let mock = server.mock(|when, then| { - when.method(GET).path("/@yarnpkg/cli-dist/-/cli-dist-4.17.1.tgz"); - then.status(200).header("content-type", "application/octet-stream").body(mock_tgz); - }); - let expected_hash = format!("sha512.{}", hex::encode(Sha512::digest(yarn_js))); + EnvConfig::with_vars_async( + [ + (env_vars::VP_HOME, vp_home.path().as_os_str()), + (env_vars::NPM_CONFIG_REGISTRY, std::ffi::OsStr::new(&other_registry.base_url())), + ], + |_| async { + download_package_manager( + PackageManagerType::Pnpm, + "12.0.0-beta.0", + Some(main_pin.as_str()), + ) + .await + .expect("seed the other registry's cache"); + }, + ) + .await; - vp_shared::EnvConfig::with_vars_async( + EnvConfig::with_vars_async( [ (env_vars::VP_HOME, vp_home.path().as_os_str()), - (env_vars::NPM_CONFIG_REGISTRY, std::ffi::OsStr::new(&server.base_url())), + (env_vars::NPM_CONFIG_REGISTRY, std::ffi::OsStr::new(&trusted.base_url())), ], |_| async { let (install_dir, _, _) = download_package_manager( - PackageManagerType::Yarn, - "4.17.1", - Some(&expected_hash), + PackageManagerType::Pnpm, + "12.0.0-beta.0", + Some(main_pin.as_str()), ) .await - .expect("Corepack's Yarn binary hash should be accepted"); - assert_eq!(mock.hits(), 1); + .expect("resolve from the trusted registry"); + let native_bin = + if cfg!(windows) { "bin/pnpm.native.exe" } else { "bin/pnpm.native" }; assert_eq!( - fs::read_to_string(install_dir.parent().unwrap().join(VERIFIED_PIN_RECORD)) - .unwrap(), - expected_hash, - "the install must record the pin it verified" + fs::read(install_dir.join(native_bin)).unwrap(), + genuine_binary, + "a cache from another registry must not be reused for the same pin" ); - - // The same pin on a warm cache reads the record instead of the CLI. - download_package_manager(PackageManagerType::Yarn, "4.17.1", Some(&expected_hash)) - .await - .expect("a recorded pin should be accepted from the cache"); - assert_eq!(mock.hits(), 1, "a cached install must not download again"); - - // A different pin does not match the record, so vp hashes the cached - // CLI and reports the mismatch. - let other_hash = format!("sha512.{}", hex::encode(Sha512::digest(b"other"))); - let result = - download_package_manager(PackageManagerType::Yarn, "4.17.1", Some(&other_hash)) - .await; - let Err(error @ Error::PackageManagerHashMismatch { .. }) = result else { - panic!("a pin the cached CLI does not match must fail: {result:?}"); - }; - let message = error.to_string(); - assert!(message.contains("yarn@4.17.1"), "{message}"); - assert!(message.contains("bin/yarn.js"), "{message}"); - assert_eq!(mock.hits(), 1, "a cached install must be checked without downloading"); }, ) .await; } #[tokio::test] - async fn test_download_modern_yarn_hashes_a_cache_without_a_record() { + async fn test_download_modern_yarn_verifies_corepack_binary_hash() { use httpmock::prelude::*; use sha2::{Digest, Sha512}; @@ -4497,7 +4696,7 @@ mod tests { let server = MockServer::start(); let yarn_js = b"#!/usr/bin/env node\nconsole.log('mock yarn');\n"; let mock_tgz = create_yarn_package_tgz(yarn_js, None); - server.mock(|when, then| { + let mock = server.mock(|when, then| { when.method(GET).path("/@yarnpkg/cli-dist/-/cli-dist-4.17.1.tgz"); then.status(200).header("content-type", "application/octet-stream").body(mock_tgz); }); @@ -4516,20 +4715,32 @@ mod tests { ) .await .expect("Corepack's Yarn binary hash should be accepted"); - - // An install by an older vp has no record. vp falls back to the CLI. - fs::remove_file(install_dir.parent().unwrap().join(VERIFIED_PIN_RECORD)).unwrap(); - fs::write(install_dir.join("bin/yarn.js"), "corrupt").unwrap(); - let result = download_package_manager( - PackageManagerType::Yarn, - "4.17.1", - Some(&expected_hash), - ) - .await; + assert_eq!(mock.hits(), 1); assert!( - matches!(result, Err(Error::PackageManagerHashMismatch { .. })), - "a cache without a record must be hashed: {result:?}" + is_package_manager_install_complete(&install_dir, "yarn").unwrap(), + "the verified CLI must be installed" ); + + // The same pin resolves to the same keyed install and reuses it. + download_package_manager(PackageManagerType::Yarn, "4.17.1", Some(&expected_hash)) + .await + .expect("the matching pin should be reused from the cache"); + assert_eq!(mock.hits(), 1, "a cached install must not download again"); + + // A different pin resolves to a different key, so vp downloads + // again and verifies the extracted CLI against the new pin, which + // the genuine CLI does not match. + let other_hash = format!("sha512.{}", hex::encode(Sha512::digest(b"other"))); + let result = + download_package_manager(PackageManagerType::Yarn, "4.17.1", Some(&other_hash)) + .await; + let Err(error @ Error::PackageManagerHashMismatch { .. }) = result else { + panic!("a pin the downloaded CLI does not match must fail: {result:?}"); + }; + let message = error.to_string(); + assert!(message.contains("yarn@4.17.1"), "{message}"); + assert!(message.contains("bin/yarn.js"), "{message}"); + assert!(mock.hits() > 1, "a different pin re-downloads to verify"); }, ) .await; @@ -5003,10 +5214,8 @@ mod tests { platform_tarball.assert_hits(1); return; } - let install_dir = - package_manager_install_dir(PackageManagerType::Bun, "1.2.0").unwrap(); assert!( - !install_dir.as_path().exists(), + installed_package_manager_dir(PackageManagerType::Bun, "1.2.0").is_none(), "a failed verification must not install Bun" ); }, @@ -5015,6 +5224,208 @@ mod tests { } } + #[tokio::test] + async fn bun_cache_from_a_different_pin_is_rejected() { + use httpmock::prelude::*; + use sha2::Sha512; + + let main_tgz = + create_tgz(&[("package/package.json", br#"{"name":"bun","version":"1.2.0"}"#)]); + let binary = b"mock bun executable"; + let binary_path = if cfg!(windows) { "package/bin/bun.exe" } else { "package/bin/bun" }; + let platform_tgz = create_tgz(&[(binary_path, binary)]); + let wrong_hash = format!("sha512.{}", hex::encode(Sha512::digest(b"other"))); + let platform_integrity = format!( + "sha512-{}", + base64_simd::STANDARD.encode_to_string(Sha512::digest(&platform_tgz)) + ); + + let server = MockServer::start(); + mount_platform_registry( + &server, + PackageManagerType::Bun, + "1.2.0", + main_tgz, + platform_tgz, + &platform_integrity, + ); + + let vp_home = create_temp_dir(); + EnvConfig::with_vars_async( + [ + (env_vars::VP_HOME, vp_home.path().as_os_str()), + (env_vars::NPM_CONFIG_REGISTRY, std::ffi::OsStr::new(&server.base_url())), + ], + |_| async { + // An unpinned install fills the cache under the no-pin key. + download_package_manager(PackageManagerType::Bun, "1.2.0", None) + .await + .expect("seed an unpinned bun into the cache"); + // A later declared pin resolves to a different key, so it does not + // reuse that cache: the install re-downloads, re-verifies, and the + // mismatch fails. + let result = download_package_manager( + PackageManagerType::Bun, + "1.2.0", + Some(wrong_hash.as_str()), + ) + .await; + assert!( + matches!(result, Err(Error::PackageManagerHashMismatch(_))), + "an unpinned bun cache must not satisfy a later declared pin: {result:?}" + ); + }, + ) + .await; + } + + #[tokio::test] + async fn bun_cache_from_a_different_registry_is_not_reused_for_the_same_pin() { + use httpmock::prelude::*; + use sha2::Sha512; + + let main_tgz = + create_tgz(&[("package/package.json", br#"{"name":"bun","version":"1.2.0"}"#)]); + let main_pin = format!("sha512.{}", hex::encode(Sha512::digest(&main_tgz))); + let binary_path = if cfg!(windows) { "package/bin/bun.exe" } else { "package/bin/bun" }; + let genuine_binary = b"genuine bun binary".to_vec(); + let other_binary = b"other bun binary".to_vec(); + let genuine_platform = create_tgz(&[(binary_path, genuine_binary.as_slice())]); + let other_platform = create_tgz(&[(binary_path, other_binary.as_slice())]); + let genuine_integrity = format!( + "sha512-{}", + base64_simd::STANDARD.encode_to_string(Sha512::digest(&genuine_platform)) + ); + let other_integrity = format!( + "sha512-{}", + base64_simd::STANDARD.encode_to_string(Sha512::digest(&other_platform)) + ); + // Both registries serve the same main tarball, so the same main pin + // matches both. They differ only in the platform binary they serve. + let other_registry = MockServer::start(); + mount_platform_registry( + &other_registry, + PackageManagerType::Bun, + "1.2.0", + main_tgz.clone(), + other_platform, + &other_integrity, + ); + let trusted = MockServer::start(); + mount_platform_registry( + &trusted, + PackageManagerType::Bun, + "1.2.0", + main_tgz, + genuine_platform, + &genuine_integrity, + ); + + let vp_home = create_temp_dir(); + // A project on another registry seeds the cache under the real, public main pin. + EnvConfig::with_vars_async( + [ + (env_vars::VP_HOME, vp_home.path().as_os_str()), + (env_vars::NPM_CONFIG_REGISTRY, std::ffi::OsStr::new(&other_registry.base_url())), + ], + |_| async { + download_package_manager(PackageManagerType::Bun, "1.2.0", Some(main_pin.as_str())) + .await + .expect("seed the other registry's cache"); + }, + ) + .await; + + // A trusted project declares the same main pin from its own registry. The + // origin in the cache key differs, so the other cache is not reused: the + // install re-downloads from the trusted registry and runs the genuine + // binary. + EnvConfig::with_vars_async( + [ + (env_vars::VP_HOME, vp_home.path().as_os_str()), + (env_vars::NPM_CONFIG_REGISTRY, std::ffi::OsStr::new(&trusted.base_url())), + ], + |_| async { + let (install_dir, _, _) = download_package_manager( + PackageManagerType::Bun, + "1.2.0", + Some(main_pin.as_str()), + ) + .await + .expect("resolve from the trusted registry"); + let native_bin = + if cfg!(windows) { "bin/bun.native.exe" } else { "bin/bun.native" }; + assert_eq!( + fs::read(install_dir.join(native_bin)).unwrap(), + genuine_binary, + "a cache from another registry must not be reused for the same pin" + ); + }, + ) + .await; + } + + #[tokio::test] + async fn platform_origin_in_the_cache_key_does_not_leak_registry_credentials() { + use httpmock::prelude::*; + use sha2::Sha512; + + let main_tgz = + create_tgz(&[("package/package.json", br#"{"name":"bun","version":"1.2.0"}"#)]); + let main_pin = format!("sha512.{}", hex::encode(Sha512::digest(&main_tgz))); + let binary_path = if cfg!(windows) { "package/bin/bun.exe" } else { "package/bin/bun" }; + let platform_tgz = create_tgz(&[(binary_path, b"bun binary")]); + let integrity = format!( + "sha512-{}", + base64_simd::STANDARD.encode_to_string(Sha512::digest(&platform_tgz)) + ); + + let server = MockServer::start(); + mount_platform_registry( + &server, + PackageManagerType::Bun, + "1.2.0", + main_tgz, + platform_tgz, + &integrity, + ); + + // A registry URL may embed credentials, which `registry_for_package` + // returns verbatim. The cache key must not persist them. + let base = server.base_url(); + let registry_with_credentials = format!("http://user:s3cr3t@{}", &base["http://".len()..]); + let vp_home = create_temp_dir(); + let install = EnvConfig::with_vars_async( + [ + (env_vars::VP_HOME, vp_home.path().as_os_str()), + (env_vars::NPM_CONFIG_REGISTRY, std::ffi::OsStr::new(®istry_with_credentials)), + ], + |_| async { + let (install, _, _) = download_package_manager( + PackageManagerType::Bun, + "1.2.0", + Some(main_pin.as_str()), + ) + .await + .expect("install bun from a credentialed registry"); + install + }, + ) + .await; + + // The platform origin is folded into the cache key as an opaque + // fingerprint, so no directory name in the managed tree carries the + // registry password. + let version_dir = install.parent().unwrap().parent().unwrap(); + for entry in fs::read_dir(version_dir.as_path()).unwrap().flatten() { + let name = entry.file_name().into_string().unwrap(); + assert!( + !name.contains("s3cr3t"), + "a cache key directory leaked the registry password: {name:?}" + ); + } + } + #[tokio::test] async fn test_download_bun_package_manager_preserves_existing_install() { let temp_dir = create_temp_dir(); @@ -5032,7 +5443,10 @@ mod tests { let config = NpmConfig { values: HashMap::from([("registry".to_string(), registry.base_url())]) }; - download_bun_package_manager(&version, &vp_home, None, &config).await.unwrap(); + EnvConfig::with_vars_async([(env_vars::VP_HOME, vp_home.as_path())], |_| async { + download_bun_package_manager(&version, None, &config).await.unwrap(); + }) + .await; assert_eq!(fs::read_to_string(native_bin).unwrap(), "existing bun"); } @@ -5063,7 +5477,7 @@ mod tests { [(PackageManagerType::Bun, "1.4.2"), (PackageManagerType::Pnpm, "12.8.1")] { write_pm_install(&vp_home, &kind.to_string(), version, InstallState::Complete); - let install = package_manager_install_dir(kind, version).unwrap(); + let install = package_manager_install_dir(kind, version, None); let bin = install.join("bin"); let native = bin.join(format!("{kind}.native.exe")); fs::write(&native, "cached native executable").unwrap(); @@ -5246,4 +5660,176 @@ mod tests { assert_eq!(find_extracted_package_dir(temp_dir.path()).unwrap(), yarn_dir); } + + /// A tarball with regular `files`, then symlink `links` (name -> target), the + /// shape a registry archive can contain for an unpinned or platform download. + fn tgz_with_symlinks(files: &[(&str, &[u8])], links: &[(&str, &str)]) -> Vec { + let mut tar_builder = tar::Builder::new(Vec::new()); + for (path, content) in files { + let mut header = tar::Header::new_gnu(); + header.set_size(content.len() as u64); + header.set_mode(0o755); + tar_builder.append_data(&mut header, path, *content).unwrap(); + } + for (name, target) in links { + let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::Symlink); + header.set_size(0); + header.set_mode(0o777); + tar_builder.append_link(&mut header, name, target).unwrap(); + } + let tar_data = tar_builder.into_inner().unwrap(); + let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + std::io::Write::write_all(&mut gz, &tar_data).unwrap(); + gz.finish().unwrap() + } + + /// Install a verified, pinned pnpm 10.34.7 and return its key directory install, + /// its pin, and the genuine `bin/pnpm.cjs` bytes. + async fn install_pinned_pnpm(vp_home: &Path) -> (AbsolutePathBuf, String, Vec) { + use httpmock::prelude::*; + use sha2::Sha512; + let cli = b"#!/usr/bin/env node\nconsole.log('genuine pnpm 10.34.7');\n".to_vec(); + let tgz = tgz_with_symlinks( + &[ + ( + "package/package.json", + br#"{"name":"pnpm","version":"10.34.7","bin":{"pnpm":"bin/pnpm.cjs"}}"#, + ), + ("package/bin/pnpm.cjs", cli.as_slice()), + ], + &[], + ); + let pin = format!("sha512.{}", hex::encode(Sha512::digest(&tgz))); + let registry = MockServer::start(); + registry.mock(move |when, then| { + when.method(GET).path("/pnpm/-/pnpm-10.34.7.tgz"); + then.status(200).body(tgz); + }); + let install_dir = EnvConfig::with_vars_async( + [ + (env_vars::VP_HOME, vp_home.as_os_str()), + (env_vars::NPM_CONFIG_REGISTRY, std::ffi::OsStr::new(®istry.base_url())), + ], + |_| async { + download_package_manager(PackageManagerType::Pnpm, "10.34.7", Some(pin.as_str())) + .await + .expect("install the pinned pnpm") + .0 + }, + ) + .await; + (install_dir, pin, cli) + } + + /// The `` directory name of a pinned install at `...///`. + fn cache_key_of(install_dir: &AbsolutePathBuf) -> String { + install_dir.as_path().parent().unwrap().file_name().unwrap().to_string_lossy().into_owned() + } + + #[tokio::test] + async fn platform_archive_symlink_cannot_write_into_another_pins_install() { + use sha2::Sha512; + let vp_home = create_temp_dir(); + let (pinned, _, _) = install_pinned_pnpm(vp_home.path()).await; + let shim_before = fs::read(pinned.as_path().join("bin/pnpm")).unwrap(); + + // An unpinned pnpm 12 whose platform tarball links its top-level `pnpm` + // entry at the pinned install. The registry serves a matching integrity. + let link = format!("../10.34.7/{}/pnpm", cache_key_of(&pinned)); + let binary_path = if cfg!(windows) { "package/pnpm.exe" } else { "package/pnpm" }; + let platform_tgz = tgz_with_symlinks( + &[(binary_path, b"#!/bin/sh\necho planted\n")], + &[("pnpm", link.as_str())], + ); + let platform_integrity = format!( + "sha512-{}", + base64_simd::STANDARD.encode_to_string(Sha512::digest(&platform_tgz)) + ); + let main_tgz = create_tgz(&[( + "package/package.json", + br#"{"name":"pnpm","version":"12.0.0-beta.0"}"#, + )]); + let registry = httpmock::MockServer::start(); + mount_platform_registry( + ®istry, + PackageManagerType::Pnpm, + "12.0.0-beta.0", + main_tgz, + platform_tgz, + &platform_integrity, + ); + let (installed, _, _) = EnvConfig::with_vars_async( + [ + (env_vars::VP_HOME, vp_home.path().as_os_str()), + (env_vars::NPM_CONFIG_REGISTRY, std::ffi::OsStr::new(®istry.base_url())), + ], + |_| async { + download_package_manager(PackageManagerType::Pnpm, "12.0.0-beta.0", None).await + }, + ) + .await + .expect("the link entry is skipped and the download installs"); + + let native_bin = if cfg!(windows) { "bin/pnpm.native.exe" } else { "bin/pnpm.native" }; + assert_eq!( + fs::read(installed.as_path().join(native_bin)).unwrap(), + b"#!/bin/sh\necho planted\n", + "the download installs into its own directory" + ); + assert!( + !pinned.as_path().join("bin/pnpm.native").exists(), + "another download wrote an executable into a verified install" + ); + assert_eq!( + fs::read(pinned.as_path().join("bin/pnpm")).unwrap(), + shim_before, + "another download rewrote a verified install's shim" + ); + } + + #[tokio::test] + async fn archive_symlink_cannot_redirect_shim_writes_into_another_install() { + use httpmock::prelude::*; + let vp_home = create_temp_dir(); + let (pinned, _, cli) = install_pinned_pnpm(vp_home.path()).await; + + // An unpinned pnpm 10.99.0 whose `bin/pnpm` is a link at the pinned + // install's `pnpm.cjs`, so writing the `pnpm` shim would follow it. + let link = format!("../../../10.34.7/{}/pnpm/bin/pnpm.cjs", cache_key_of(&pinned)); + let tgz = tgz_with_symlinks( + &[ + ( + "package/package.json", + br#"{"name":"pnpm","version":"10.99.0","bin":{"pnpm":"bin/pnpm.cjs"}}"#, + ), + ("package/bin/pnpm.cjs", b"console.log('other');\n"), + ], + &[("package/bin/pnpm", link.as_str())], + ); + let registry = MockServer::start(); + registry.mock(move |when, then| { + when.method(GET).path("/pnpm/-/pnpm-10.99.0.tgz"); + then.status(200).body(tgz); + }); + let (installed, _, _) = EnvConfig::with_vars_async( + [ + (env_vars::VP_HOME, vp_home.path().as_os_str()), + (env_vars::NPM_CONFIG_REGISTRY, std::ffi::OsStr::new(®istry.base_url())), + ], + |_| async { download_package_manager(PackageManagerType::Pnpm, "10.99.0", None).await }, + ) + .await + .expect("the link entry is skipped and the download installs"); + + assert!( + fs::symlink_metadata(installed.as_path().join("bin/pnpm")).unwrap().is_file(), + "the shim is written as a regular file in the download's own directory" + ); + assert_eq!( + fs::read(pinned.as_path().join("bin/pnpm.cjs")).unwrap(), + cli, + "another download rewrote a verified install's entry point" + ); + } } diff --git a/crates/vp_pm_cli/src/package_manager/bootstrap.rs b/crates/vp_pm_cli/src/package_manager/bootstrap.rs index 4ee9e6fc1b..5f0708b721 100644 --- a/crates/vp_pm_cli/src/package_manager/bootstrap.rs +++ b/crates/vp_pm_cli/src/package_manager/bootstrap.rs @@ -5,8 +5,8 @@ use vp_error::Error; use vt_path::AbsolutePathBuf; use super::{ - PackageManagerType, download_package_manager_inner, is_package_manager_install_complete, - package_manager_install_dir, + PackageManagerType, download_package_manager_inner, install_cache_key, + is_package_manager_install_complete, package_manager_install_dir, }; use crate::{config::NpmConfig, request::HttpClient}; @@ -20,13 +20,19 @@ struct Metadata { #[derive(Deserialize)] struct Distribution { tarball: String, - integrity: String, } /// Keep the normal cache, authentication, extraction, and installation lock. -/// Metadata supplies the registry's tarball URL and integrity for the exact pin. +/// Metadata supplies the registry's tarball URL for the exact pin. +/// +/// `expected_integrity` is the `packageManager`-format hash (`sha512.`) +/// that the caller pins in the binary. The download is verified against it, not +/// against any value the registry reports. A project `.npmrc` may point the +/// registry at any host, so the registry's own integrity cannot be trusted. A +/// genuine registry or mirror serves identical bytes and still matches the pin. pub async fn bootstrap_pnpm( version: &str, + expected_integrity: &str, registry: Option<&str>, ) -> Result { let parsed = semver::Version::parse(version) @@ -36,11 +42,15 @@ pub async fn bootstrap_pnpm( "pnpm bootstrap requires a JavaScript pnpm release".into(), )); } - let install = package_manager_install_dir(PackageManagerType::Pnpm, version) - .expect("pnpm has an install directory"); - if is_package_manager_install_complete(&install, "pnpm")? - && install.join("bin/pnpm.cjs").as_path().is_file() - { + // A JavaScript pnpm release is a tarball manager, so its pinned install is + // key-addressed by the pin alone (no platform origin). A complete keyed + // install holds content verified against this pin; reuse it. An install a + // different pin produced lives under a different key and is not reused, so it + // triggers a fresh, verified download. + let cache_key = install_cache_key(Some(expected_integrity), None); + let install = + package_manager_install_dir(PackageManagerType::Pnpm, version, cache_key.as_deref()); + if is_package_manager_install_complete(&install, "pnpm")? { return Ok(install); } @@ -51,15 +61,14 @@ pub async fn bootstrap_pnpm( let metadata: Metadata = HttpClient::with_npm_config(3, 500, config.clone()) .get_json(&config.package_version_url("pnpm", version)) .await?; - if metadata.name != "pnpm" || metadata.version != version || metadata.dist.integrity.is_empty() - { + if metadata.name != "pnpm" || metadata.version != version { return Err(Error::InvalidArgument("Registry returned an unexpected pnpm package".into())); } let (install, _, _) = download_package_manager_inner( None, PackageManagerType::Pnpm, version, - Some(&metadata.dist.integrity), + Some(expected_integrity), &config, Some(&metadata.dist.tarball), ) diff --git a/crates/vp_pm_cli/src/request.rs b/crates/vp_pm_cli/src/request.rs index a3970b2f13..434383762e 100644 --- a/crates/vp_pm_cli/src/request.rs +++ b/crates/vp_pm_cli/src/request.rs @@ -389,6 +389,14 @@ impl HttpClient { } } +/// Extract a tgz archive into `target_dir`, skipping symbolic and hard link +/// entries. +/// +/// Callers then build the install tree inside `target_dir` with +/// `create_dir_all`, `rename` and shim writes, and those follow links, so an +/// archive-supplied link could redirect them into another install or any +/// existing directory. npm skips link entries in package tarballs the same way. +/// Otherwise this matches `Archive::unpack`. fn extract_tgz(tgz_file: impl AsRef, target_dir: impl AsRef) -> Result<(), Error> { let tgz_file = tgz_file.as_ref(); let target_dir = target_dir.as_ref(); @@ -397,7 +405,29 @@ fn extract_tgz(tgz_file: impl AsRef, target_dir: impl AsRef) -> Resu let file = std::fs::File::open(tgz_file)?; let tar_stream = GzDecoder::new(file); let mut archive = Archive::new(tar_stream); - archive.unpack(target_dir)?; + if target_dir.symlink_metadata().is_err() { + std::fs::create_dir_all(target_dir)?; + } + // Canonicalize as `Archive::unpack` does, so Windows accepts long paths. + let target_dir = target_dir.canonicalize().unwrap_or_else(|_| target_dir.to_path_buf()); + // Apply directories last, deepest first, as `Archive::unpack` does, so a + // directory's permissions cannot block extracting its contents. + let mut directories = Vec::new(); + for entry in archive.entries()? { + let mut entry = entry?; + let entry_type = entry.header().entry_type(); + if entry_type.is_symlink() || entry_type.is_hard_link() { + tracing::debug!("Skip link entry {:?}", entry.path()?); + } else if entry_type.is_dir() { + directories.push(entry); + } else { + entry.unpack_in(&target_dir)?; + } + } + directories.sort_by(|a, b| b.path_bytes().cmp(&a.path_bytes())); + for mut directory in directories { + directory.unpack_in(&target_dir)?; + } tracing::debug!("Extract tgz finished"); @@ -407,7 +437,7 @@ fn extract_tgz(tgz_file: impl AsRef, target_dir: impl AsRef) -> Resu /// Extract exactly one regular file from a tgz archive. /// /// Unlike [`extract_tgz`], this function never writes an archive-controlled -/// path or link. Use it when an integrity pin covers one file inside an +/// path. Use it when an integrity pin covers one file inside an /// unauthenticated archive. fn extract_tgz_file( tgz_file: impl AsRef, diff --git a/crates/vp_pm_cli/tests/package_manager_pin_verification.rs b/crates/vp_pm_cli/tests/package_manager_pin_verification.rs new file mode 100644 index 0000000000..56cd506fc8 --- /dev/null +++ b/crates/vp_pm_cli/tests/package_manager_pin_verification.rs @@ -0,0 +1,365 @@ +//! Integrity tests for the pinned package manager that setup installs. +//! +//! A project `.npmrc` selects the registry that first-start setup uses to fetch +//! its pinned pnpm. Setup must verify the download against an integrity value the +//! binary carries, not one the registry reports, and that pin must also gate +//! cache reuse. The managed install is key-addressed by what it was verified +//! against, so a different pin, or no pin, resolves to a different directory. +//! These tests confirm: +//! +//! 1. A fresh download whose bytes do not match the pin is rejected. +//! 2. A fresh download whose bytes match the pin installs, so configured +//! registries and mirrors keep working. +//! 3. A cached install a different pin produced is not reused for this pin. +//! 4. A cached install this pin produced is reused without a new download. +//! 5. The shim path applies the same pin gate as the download path, so a cache an +//! unpinned download filled is not handed to a project that declared the pin. +//! 6. No archive entry can write outside its install directory, by `..` or +//! through a symlink. + +// This test builds tarball fixtures and changes the working directory, so it +// uses std helpers the workspace restricts in production code. +#![allow(clippy::disallowed_macros, clippy::disallowed_types, clippy::disallowed_methods)] + +use std::{ffi::OsStr, future::Future}; + +use httpmock::prelude::*; +use sha2::{Digest, Sha512}; +use vp_pm_cli::{ + PackageManagerType, bootstrap_pnpm, download_package_manager, ensure_package_manager_bin, + installed_package_manager_dir, package_manager_install_dir, +}; +use vp_shared::{EnvConfig, env_vars}; + +const PINNED_PNPM_VERSION: &str = "10.34.7"; +/// Content marker for bytes that do not match the pin, so a test can tell which +/// pnpm an install holds. +const MARKER: &str = "MISMATCHED_PNPM_CONTENT"; + +/// Build a pnpm npm tarball whose `bin/pnpm.cjs` carries `marker`. +fn pnpm_tgz(marker: &str) -> Vec { + pnpm_tgz_with(marker, |_| {}) +} + +/// Build a pnpm npm tarball whose `bin/pnpm.cjs` carries `marker`, then let +/// `extra` append further entries. +fn pnpm_tgz_with(marker: &str, extra: impl FnOnce(&mut tar::Builder>)) -> Vec { + let mut tar_builder = tar::Builder::new(Vec::new()); + + let package_json = format!( + r#"{{"name":"pnpm","version":"{PINNED_PNPM_VERSION}","bin":{{"pnpm":"bin/pnpm.cjs"}}}}"# + ); + append_file(&mut tar_builder, "package/package.json", &package_json); + + let pnpm_cjs = format!("#!/usr/bin/env node\n// {marker}\nconsole.log('pnpm');\n"); + let mut header = tar::Header::new_gnu(); + header.set_size(pnpm_cjs.len() as u64); + header.set_mode(0o755); + tar_builder + .append_data(&mut header, "package/bin/pnpm.cjs", std::io::Cursor::new(pnpm_cjs)) + .unwrap(); + + extra(&mut tar_builder); + + let tar_data = tar_builder.into_inner().unwrap(); + let mut gz_data = Vec::new(); + { + let mut encoder = + flate2::write::GzEncoder::new(&mut gz_data, flate2::Compression::default()); + std::io::copy(&mut std::io::Cursor::new(tar_data), &mut encoder).unwrap(); + } + gz_data +} + +/// Append a regular file at `name`, which the builder validates. +fn append_file(tar_builder: &mut tar::Builder>, name: &str, contents: &str) { + let mut header = tar::Header::new_gnu(); + header.set_size(contents.len() as u64); + header.set_mode(0o644); + tar_builder.append_data(&mut header, name, std::io::Cursor::new(contents.as_bytes())).unwrap(); +} + +/// Append a regular file whose name is written into the header unvalidated. The +/// builder refuses `..`, so a test that needs such a name writes the header bytes +/// directly. +fn append_raw_file(tar_builder: &mut tar::Builder>, raw_name: &str, contents: &str) { + let mut header = tar::Header::new_gnu(); + header.as_gnu_mut().unwrap().name[..raw_name.len()].copy_from_slice(raw_name.as_bytes()); + header.set_entry_type(tar::EntryType::Regular); + header.set_size(contents.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + tar_builder.append(&header, std::io::Cursor::new(contents.as_bytes())).unwrap(); +} + +/// Append a symlink at `name` that points at `target`. +fn append_symlink(tar_builder: &mut tar::Builder>, name: &str, target: &str) { + let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::Symlink); + header.set_size(0); + header.set_mode(0o777); + tar_builder.append_link(&mut header, name, target).unwrap(); +} + +/// The `packageManager`-format integrity (`sha512.`) of a tarball. +fn pin(tgz: &[u8]) -> String { + format!("sha512.{}", hex::encode(Sha512::digest(tgz))) +} + +/// Answer the pnpm metadata and tarball requests on `server` with `tgz`. The +/// mocks stay active without their handles, like the runner's other cases. +fn mount_pnpm_registry(server: &MockServer, tgz: Vec) { + let tarball_url = format!("{}/pnpm/-/pnpm-{PINNED_PNPM_VERSION}.tgz", server.base_url()); + server.mock(|when, then| { + when.method(GET).path(format!("/pnpm/{PINNED_PNPM_VERSION}")); + then.status(200).json_body(serde_json::json!({ + "name": "pnpm", + "version": PINNED_PNPM_VERSION, + "dist": { "tarball": tarball_url }, + })); + }); + server.mock(|when, then| { + when.method(GET).path(format!("/pnpm/-/pnpm-{PINNED_PNPM_VERSION}.tgz")); + then.status(200).body(tgz); + }); +} + +/// Run `f` with the working directory set to a project whose `.npmrc` names +/// `registry_base`, a fresh `VP_HOME`, and the registry env cleared. Return the +/// temporary `VP_HOME` so the caller keeps the install tree alive. +async fn in_project_env(registry_base: &str, f: F) -> (R, tempfile::TempDir) +where + F: FnOnce() -> Fut, + Fut: Future, +{ + let project = tempfile::tempdir().unwrap(); + std::fs::write(project.path().join("package.json"), r#"{"name":"demo","version":"1.0.0"}"#) + .unwrap(); + std::fs::write(project.path().join(".npmrc"), format!("registry={registry_base}\n")).unwrap(); + + let original_cwd = std::env::current_dir().unwrap(); + std::env::set_current_dir(project.path()).unwrap(); + + let vp_home = tempfile::tempdir().unwrap(); + let vp_home_os = vp_home.path().as_os_str().to_owned(); + + let result = EnvConfig::with_vars_async( + [ + (env_vars::VP_HOME, Some(vp_home_os.as_os_str())), + (env_vars::NPM_CONFIG_REGISTRY, None::<&OsStr>), + (env_vars::NPM_CONFIG_REGISTRY_UPPER, None::<&OsStr>), + ("registry", None::<&OsStr>), + ], + |_| async { f().await }, + ) + .await; + + std::env::set_current_dir(original_cwd).unwrap(); + (result, vp_home) +} + +/// All scenarios run in one test, because each one changes the process working +/// directory. Separate `#[test]` functions would race on that global state. +#[tokio::test(flavor = "current_thread")] +async fn pinned_integrity_gates_setup_pnpm() { + fresh_download_not_matching_the_pin_is_rejected().await; + fresh_download_matching_the_pin_installs().await; + cached_install_from_a_different_pin_is_rejected().await; + matching_pin_cache_is_reused().await; + shim_path_does_not_reuse_an_unpinned_cache().await; + archive_entries_cannot_escape_the_install_directory().await; +} + +/// A project `.npmrc` names a registry whose bytes do not match the pin. The +/// bundled pin rejects the fresh download, and nothing is installed. +async fn fresh_download_not_matching_the_pin_is_rejected() { + let server = MockServer::start(); + let official = pin(&pnpm_tgz("genuine")); + mount_pnpm_registry(&server, pnpm_tgz(MARKER)); + + let (result, _vp_home) = in_project_env(&server.base_url(), || async { + let result = bootstrap_pnpm(PINNED_PNPM_VERSION, &official, None).await; + let installed = + installed_package_manager_dir(PackageManagerType::Pnpm, PINNED_PNPM_VERSION); + (result, installed) + }) + .await; + + let (result, installed) = result; + let error = result.expect_err("a download not matching the pin must fail integrity"); + assert!(error.is_integrity_failure(), "expected an integrity failure, got: {error:?}"); + assert!(installed.is_none(), "no mismatched pnpm must be installed"); +} + +/// A configured registry or private mirror serves the pinned bytes, so the fresh +/// download installs and `.npmrc` support is unchanged. +async fn fresh_download_matching_the_pin_installs() { + let server = MockServer::start(); + let genuine = pnpm_tgz("genuine"); + let official = pin(&genuine); + mount_pnpm_registry(&server, genuine); + + let (result, _vp_home) = in_project_env(&server.base_url(), || async { + bootstrap_pnpm(PINNED_PNPM_VERSION, &official, None).await + }) + .await; + + let install_dir = result.expect("the pinned pnpm bytes must install"); + assert!( + std::fs::read_to_string(install_dir.join("bin").join("pnpm.cjs").as_path()) + .unwrap() + .contains("genuine"), + "the genuine pnpm.cjs must be installed", + ); +} + +/// A prior operation caches a pnpm of the pinned version under a different pin. +/// That cache lives under a different key, so setup with the bundled pin does not +/// reuse it. It downloads again and verifies, which the mismatched bytes fail. +async fn cached_install_from_a_different_pin_is_rejected() { + let server = MockServer::start(); + let mismatched = pnpm_tgz(MARKER); + let mismatched_pin = pin(&mismatched); + let official = pin(&pnpm_tgz("genuine")); + mount_pnpm_registry(&server, mismatched); + + let (result, _vp_home) = in_project_env(&server.base_url(), || async { + let (seeded, _, _) = download_package_manager( + PackageManagerType::Pnpm, + PINNED_PNPM_VERSION, + Some(mismatched_pin.as_str()), + ) + .await + .expect("seed a pnpm into the shared cache under a different pin"); + let seeded_mismatched = + std::fs::read_to_string(seeded.join("bin").join("pnpm.cjs").as_path()) + .unwrap() + .contains(MARKER); + (bootstrap_pnpm(PINNED_PNPM_VERSION, &official, None).await, seeded_mismatched) + }) + .await; + + let (result, seeded_mismatched) = result; + assert!(seeded_mismatched, "the cache must really hold the mismatched pnpm"); + let error = result.expect_err("a cache under a different pin must not satisfy the bundled pin"); + assert!(error.is_integrity_failure(), "expected an integrity failure, got: {error:?}"); +} + +/// A cache that the bundled pin installed is reused without a new download or a +/// metadata request, so the cache fast path still serves matching pins. +async fn matching_pin_cache_is_reused() { + let server = MockServer::start(); + let genuine = pnpm_tgz("genuine"); + let official = pin(&genuine); + let tarball_url = format!("{}/pnpm/-/pnpm-{PINNED_PNPM_VERSION}.tgz", server.base_url()); + let metadata = server.mock(|when, then| { + when.method(GET).path(format!("/pnpm/{PINNED_PNPM_VERSION}")); + then.status(200).json_body(serde_json::json!({ + "name": "pnpm", + "version": PINNED_PNPM_VERSION, + "dist": { "tarball": tarball_url }, + })); + }); + let tarball = server.mock(|when, then| { + when.method(GET).path(format!("/pnpm/-/pnpm-{PINNED_PNPM_VERSION}.tgz")); + then.status(200).body(genuine); + }); + let metadata_ref = &metadata; + let tarball_ref = &tarball; + + let (reused_genuine, _vp_home) = in_project_env(&server.base_url(), || async move { + let (seeded, _, _) = download_package_manager( + PackageManagerType::Pnpm, + PINNED_PNPM_VERSION, + Some(official.as_str()), + ) + .await + .expect("seed a genuine pnpm into the shared cache"); + let downloads_after_seed = tarball_ref.hits(); + + let dir = bootstrap_pnpm(PINNED_PNPM_VERSION, &official, None) + .await + .expect("a matching pin must reuse the cache"); + + assert_eq!(dir, seeded, "the reused install is the seeded one"); + assert_eq!(tarball_ref.hits(), downloads_after_seed, "reuse must not download again"); + assert_eq!(metadata_ref.hits(), 0, "reuse must not fetch metadata"); + std::fs::read_to_string(dir.join("bin").join("pnpm.cjs").as_path()) + .unwrap() + .contains("genuine") + }) + .await; + + assert!(reused_genuine, "the reused install must be the genuine pnpm"); +} + +/// The global shim resolves a package manager through `ensure_package_manager_bin` +/// with the project's declared `packageManager` hash. An unpinned download caches +/// under a different key, so the pinned shim path does not reuse it. It downloads +/// again and verifies, which the mismatched bytes fail. +async fn shim_path_does_not_reuse_an_unpinned_cache() { + let server = MockServer::start(); + let official = pin(&pnpm_tgz("genuine")); + mount_pnpm_registry(&server, pnpm_tgz(MARKER)); + + let (result, _vp_home) = in_project_env(&server.base_url(), || async { + let (seeded, _, _) = + download_package_manager(PackageManagerType::Pnpm, PINNED_PNPM_VERSION, None) + .await + .expect("seed the mismatched archive through an unpinned download"); + let seeded_mismatched = + std::fs::read_to_string(seeded.join("bin").join("pnpm.cjs").as_path()) + .unwrap() + .contains(MARKER); + let resolved = ensure_package_manager_bin( + PackageManagerType::Pnpm, + PINNED_PNPM_VERSION, + Some(official.as_str()), + "pnpm", + ) + .await; + (resolved, seeded_mismatched) + }) + .await; + + let (resolved, seeded_mismatched) = result; + assert!(seeded_mismatched, "the cache must really hold the mismatched pnpm"); + let error = resolved.expect_err("the shim must not reuse a cache this pin did not install"); + assert!(error.is_integrity_failure(), "expected an integrity failure, got: {error:?}"); +} + +/// The staging directory sits in the version directory's parent, so trusting the +/// managed tree depends on extraction refusing to leave its destination, whether +/// by a `..` entry or through a symlink. Either kind of entry may be skipped or +/// may fail the extraction, but neither may write a file beside the version +/// directory. +async fn archive_entries_cannot_escape_the_install_directory() { + let escape_name = "escaped.txt"; + let escape_marker = "ESCAPED"; + let traversal = pnpm_tgz_with(MARKER, |tar_builder| { + append_raw_file(tar_builder, &format!("../{escape_name}"), escape_marker); + }); + let through_symlink = pnpm_tgz_with(MARKER, |tar_builder| { + append_symlink(tar_builder, "escape", ".."); + append_file(tar_builder, &format!("escape/{escape_name}"), escape_marker); + }); + + for (vector, archive) in [("traversal", traversal), ("symlink", through_symlink)] { + let server = MockServer::start(); + mount_pnpm_registry(&server, archive); + let ((escaped, extraction), _vp_home) = in_project_env(&server.base_url(), || async { + let extraction = + download_package_manager(PackageManagerType::Pnpm, PINNED_PNPM_VERSION, None).await; + // The managed directory holding this version's installs. + let install = + package_manager_install_dir(PackageManagerType::Pnpm, PINNED_PNPM_VERSION, None); + let manager_dir = install.parent().unwrap().parent().unwrap(); + (manager_dir.join(escape_name).as_path().exists(), format!("{extraction:?}")) + }) + .await; + assert!( + !escaped, + "a {vector} entry wrote a file beside the version directory (extraction: {extraction})" + ); + } +} diff --git a/crates/vp_setup/src/install.rs b/crates/vp_setup/src/install.rs index 8c1fb3bc14..646f5bea2c 100644 --- a/crates/vp_setup/src/install.rs +++ b/crates/vp_setup/src/install.rs @@ -124,6 +124,18 @@ pub async fn extract_platform_package( /// This ensures consistent install behavior regardless of the user's global pnpm version. const PINNED_PNPM_VERSION: &str = "10.34.7"; +/// Integrity of the pinned pnpm npm tarball, in the `packageManager` hash +/// format (`sha512.`). Setup verifies the downloaded pnpm against this +/// value, which the binary carries. +/// +/// A project `.npmrc` may point the registry at any host, so setup must not +/// trust an integrity value that the download source reports for its own +/// tarball. This pin is the `sha512` of the official npm tarball for +/// `PINNED_PNPM_VERSION`. A genuine registry or mirror serves identical bytes +/// and still matches it, so private registries keep working. Update this value +/// together with `PINNED_PNPM_VERSION`. +const PINNED_PNPM_INTEGRITY: &str = "sha512.ba98abc585539933375b5971f4bc6d6c73a1b4b7edc4a879e7d102fe5423d1e02309d67c60a11f04955093a96e521ad9c3b138cdd654ca5be563f378b3ae48bd"; + /// A reused target version must run its own setup again before accepting commands. pub async fn clear_self_setup_marker(version_dir: &AbsolutePath) -> Result<(), Error> { match tokio::fs::remove_file(version_dir.join("bin").join(crate::SELF_SETUP_MARKER)).await { @@ -146,7 +158,7 @@ pub async fn generate_wrapper_package_json( "name": "vp-global", "version": version, "private": true, - "packageManager": format!("pnpm@{PINNED_PNPM_VERSION}"), + "packageManager": format!("pnpm@{PINNED_PNPM_VERSION}+{PINNED_PNPM_INTEGRITY}"), "dependencies": { "vite-plus": version } @@ -338,9 +350,11 @@ async fn prepare_install_runtime( node_version: Option>, registry: Option<&str>, ) -> Result<(vp_js_runtime::JsRuntime, AbsolutePathBuf), Error> { - let pnpm_dir = bootstrap_pnpm(PINNED_PNPM_VERSION, registry).await.map_err(|error| { - Error::Setup(format!("Failed to install pnpm {PINNED_PNPM_VERSION}: {error}").into()) - })?; + let pnpm_dir = bootstrap_pnpm(PINNED_PNPM_VERSION, PINNED_PNPM_INTEGRITY, registry) + .await + .map_err(|error| { + Error::Setup(format!("Failed to install pnpm {PINNED_PNPM_VERSION}: {error}").into()) + })?; let pnpm_entry = pnpm_dir.join("bin").join("pnpm.cjs"); if !tokio::fs::try_exists(&pnpm_entry).await.unwrap_or(false) { return Err(Error::Setup( @@ -829,12 +843,14 @@ mod tests { (env_vars::VP_NODE_DIST_MIRROR, server.base_url().as_str()), ], |env| async move { - let pnpm = env - .dirs - .data - .join("package_manager/pnpm") - .join(PINNED_PNPM_VERSION) - .join("pnpm"); + // Key-address the seeded pnpm by its pin, so setup reuses it from + // the cache instead of fetching it from a registry. + let cache_key = vp_pm_cli::install_cache_key(Some(PINNED_PNPM_INTEGRITY), None); + let pnpm = vp_pm_cli::package_manager_install_dir( + vp_pm_cli::PackageManagerType::Pnpm, + PINNED_PNPM_VERSION, + cache_key.as_deref(), + ); tokio::fs::create_dir_all(pnpm.join("bin")).await.unwrap(); for name in ["pnpm", "pnpm.cmd", "pnpm.ps1", "pnpm.cjs"] { tokio::fs::write(pnpm.join("bin").join(name), "").await.unwrap(); diff --git a/packages/cli/binding/Cargo.toml b/packages/cli/binding/Cargo.toml index 0f76202ab1..126e965802 100644 --- a/packages/cli/binding/Cargo.toml +++ b/packages/cli/binding/Cargo.toml @@ -48,7 +48,10 @@ oxc-node = { workspace = true, optional = true } napi-build = { workspace = true } [dev-dependencies] +flate2 = { workspace = true } +httpmock = { workspace = true } pretty_assertions = { workspace = true } +tar = { workspace = true } tempfile = { workspace = true } vp_shared = { workspace = true, features = ["test-utils"] } diff --git a/packages/cli/binding/src/cli/mod.rs b/packages/cli/binding/src/cli/mod.rs index 02046ec96f..159917bd03 100644 --- a/packages/cli/binding/src/cli/mod.rs +++ b/packages/cli/binding/src/cli/mod.rs @@ -560,18 +560,13 @@ mod tests { #[tokio::test] async fn stops_when_the_pinned_package_manager_fails_its_integrity_check() { + use httpmock::prelude::*; + let suffix = SystemTime::now().duration_since(UNIX_EPOCH).expect("time should be valid").as_nanos(); let temp_dir = std::env::temp_dir().join(format!("vite-plus-bad-hash-{suffix}")); let vp_home = temp_dir.join("vp-home"); - // VP_HOME pins to the root, so the cached install lands here. - let bin_dir = - vp_home.join("package_manager").join("yarn").join("4.17.1").join("yarn").join("bin"); - fs::create_dir_all(&bin_dir).expect("cached package manager should be created"); - for shim in ["yarn", "yarn.cmd", "yarn.ps1"] { - fs::write(bin_dir.join(shim), "shim").expect("shim should be written"); - } - fs::write(bin_dir.join("yarn.js"), "corrupt").expect("CLI should be written"); + fs::create_dir_all(&temp_dir).expect("temp dir should be created"); let expected_hash = format!("sha512.{}", "0".repeat(128)); fs::write( @@ -579,22 +574,62 @@ mod tests { format!(r#"{{"name":"fixture","packageManager":"yarn@4.17.1+{expected_hash}"}}"#), ) .expect("package.json should be written"); + + // A Yarn 2+ pin covers the CLI binary, verified when it is downloaded. Serve + // the `@yarnpkg/cli-dist` tarball from a local registry so the check runs + // offline; its CLI bytes cannot match the all-zeros pin, so the download must + // fail and the error must reach the caller instead of a missing command. + let server = MockServer::start(); + let tarball = cli_dist_tgz(b"console.log('yarn');\n"); + server.mock(|when, then| { + when.method(GET).path("/@yarnpkg/cli-dist/-/cli-dist-4.17.1.tgz"); + then.status(200).body(tarball); + }); + let registry = server.base_url(); + let cwd = AbsolutePathBuf::new(temp_dir.clone()).expect("temp dir should be absolute"); let original_path = std::env::join_paths([temp_dir.join("old-bin")]).expect("valid PATH"); let envs = envs_with_path(original_path.as_os_str()); - vp_shared::EnvConfig::with_vars_async([(vp_shared::env_vars::VP_HOME, &vp_home)], |_| async { - let result = envs_with_explicit_package_manager_path(&cwd, envs).await; - - assert!( - matches!(result, Err(Error::PackageManagerHashMismatch(_))), - "an integrity failure must reach the user instead of a missing command: {result:?}" - ); - fs::remove_dir_all(temp_dir).expect("temp dir should be removed"); - }) + vp_shared::EnvConfig::with_vars_async( + [ + (vp_shared::env_vars::VP_HOME, vp_home.as_os_str()), + ("NPM_CONFIG_REGISTRY", std::ffi::OsStr::new(®istry)), + ], + |_| async { + let result = envs_with_explicit_package_manager_path(&cwd, envs).await; + + assert!( + matches!(result, Err(Error::PackageManagerHashMismatch(_))), + "an integrity failure must reach the user instead of a missing command: {result:?}" + ); + fs::remove_dir_all(temp_dir).expect("temp dir should be removed"); + }, + ) .await; } + /// Build an `@yarnpkg/cli-dist` style gzip tarball holding `package/bin/yarn.js`. + fn cli_dist_tgz(yarn_js: &[u8]) -> Vec { + let mut builder = tar::Builder::new(Vec::new()); + let mut header = tar::Header::new_gnu(); + header.set_size(yarn_js.len() as u64); + header.set_mode(0o755); + header.set_cksum(); + builder + .append_data(&mut header, "package/bin/yarn.js", std::io::Cursor::new(yarn_js)) + .unwrap(); + let tar_data = builder.into_inner().unwrap(); + let mut gz = Vec::new(); + { + let mut encoder = + flate2::write::GzEncoder::new(&mut gz, flate2::Compression::default()); + std::io::Write::write_all(&mut encoder, &tar_data).unwrap(); + encoder.finish().unwrap(); + } + gz + } + #[tokio::test] async fn ignores_lockfile_without_explicit_package_manager() { let suffix = diff --git a/rfcs/package-manager-detection.md b/rfcs/package-manager-detection.md index b1820e91f1..a3be020211 100644 --- a/rfcs/package-manager-detection.md +++ b/rfcs/package-manager-detection.md @@ -143,7 +143,7 @@ A `packageManager` field can carry an integrity hash: `yarn@4.17.1+sha512.ccbf | pnpm ≥ 12 | the main `pnpm` tarball | the platform package against the registry `dist.integrity` | | bun | the main `bun` tarball | the platform package against the registry `dist.integrity` | -For a fresh Bun install, Vite+ downloads the main `bun` tarball only when a declared hash needs verification. It still installs the platform-specific binary and verifies that package against the registry's `dist.integrity` when present. Cached Bun installs do not yet recheck declared pins. +For a fresh Bun install, Vite+ downloads the main `bun` tarball only when a declared hash needs verification. It still installs the platform-specific binary and verifies that package against the registry's `dist.integrity` when present. Yarn 2 and later is the exception because Corepack installs Berry from a single file, `repo.yarnpkg.com//packages/yarnpkg-cli/bin/yarn.js`, and hashes that file. Vite+ downloads the `@yarnpkg/cli-dist` tarball instead, so it extracts `bin/yarn.js` and hashes that entry. The bytes are the same; only the basis differs. Vite+ hashed the tarball before, which made a pin written by `corepack use` fail (issue #2209). @@ -151,13 +151,15 @@ That pin covers one file inside an otherwise unauthenticated archive, so Vite+ w ### When Vite+ verifies a pin -Vite+ hashes the artifact when it downloads it, and records the verified pin beside the install in `/.verified-pin`. A later command compares its own pin against that record: +Vite+ hashes the artifact when it downloads it, then stores the verified install in a content-addressed directory under a hidden `.verified-v1` namespace, separate from the unpinned version directories: `/package_manager//.verified-v1///`, where `` is a fingerprint of the pin (and, for a platform-binary manager, the registry origin). A later command resolves that same directory only when its pin, and origin, match: -- The pins match. The command uses the cache and reads no further. -- The pins differ, or the record is missing. Vite+ hashes the cached CLI once, then rewrites the record. +- The pins match. The command finds the complete keyed directory and reuses it, without re-reading the install. +- The pins differ. The request resolves to a different key directory, which does not exist, so Vite+ downloads and verifies again. - The hash disagrees with the pin. The command stops with `Hash mismatch for @`, and the message names the artifact the hash covers. -Vite+ does not read the CLI again on every command. Corepack gives the same guarantee: it reads its own `.corepack` record and returns. The trust boundary is write access to `$VP_HOME`, which also holds the `vp` binary, the generated shims, and the managed Node.js runtime. +A complete keyed directory is never replaced, so a verified install stays stable while a command runs it. An install with no pin has no integrity reference, so it stays on the shared `/` directory outside the verified namespace, with no key segment; a pinned request never reuses it. + +Vite+ does not read the CLI again on every command. The trust boundary is write access to `$VP_HOME`, which also holds the `vp` binary, the generated shims, and the managed Node.js runtime. An integrity failure stops the command that needs the package manager, including `vp run` and `vp exec`. Those commands otherwise continue when the managed package manager is missing, for example with no network or an unknown version. A swallowed integrity failure would surface later as "command not found". @@ -207,9 +209,9 @@ Each package manager has specific files that trigger cache invalidation when cha - **File**: `crates/vp_pm_cli/src/package_manager.rs` - **Function**: `get_package_manager_type_and_version()` — priority-ordered detection -- **Function**: `download_package_manager()` — download, hash, and record the verified pin +- **Function**: `download_package_manager()` — download, hash, and store the verified install under its content key - **Function**: `ensure_package_manager_bin()` — resolve the executable, shared with the global shim -- **Function**: `verify_cached_cli_hash()` — compare a pin against the recorded pin +- **Function**: `install_cache_key()` — derive a pinned install's content-key directory from the pin and, for a platform-binary manager, the registry origin - **Enum**: `PackageManagerType` — `Pnpm`, `Yarn`, `Npm`, `Bun` ### TypeScript (CLI integration)