Files
pnpm/pnpm11/cache/commands/test/cacheDelete.cmd.test.ts
T
Ayush SinghandZoltan Kochan 584b6c8388 fix(resolving): encode full registry path into metadata cache key (#14081)
Registry metadata mirrors were keyed on host[:port], so several registries
served from one host under different path prefixes shared one directory and
could answer with each other's versions, integrity hashes and tarball URLs.

Both stacks now key the mirror on
<scheme>%3A+<host>[+<port>][%2F<path>][%5F<sha256>]. The host and every path
segment are percent-escaped down to [A-Za-z0-9._-], so a `%` or `+` in a key
is always one the encoder wrote and the key can carry no path separator, no
character Windows rejects in a filename, and no glob metacharacter — the
cache commands feed the key to a glob whose matches `pnpm cache delete`
removes. The scheme is included because http and https at one host and path
are two different trust domains: metadata served over http can be rewritten
in transit and must never reach a resolution configured for https. The
separators are percent-escapes because every key pnpm wrote before this
change was a URL host, which can never hold a `%`; no new key can therefore
land on the stale directory of an unrelated one whose hostname held a
separator, which would otherwise let `https://nexus/npm/` read what was
cached for `https://nexus_npm/`. A path that is not all lowercase gets a
sha256 suffix, the guard encodePkgName already applies to package names, so
HFS+ and NTFS cannot merge two registries; a trailing `.` is escaped because
Win32 strips one; and a key too long for a 255-byte filename is replaced by
its own hash. Only the one trailing slash the resolver itself appends is
normalized away; a repeated slash reaches the registry as a distinct request
path and stays in the key.

Every cache directory is renamed, so the first install after upgrading
refetches registry metadata once. The package store is untouched.

`pnpm cache view` decodes the key back to the registry rather than replacing
`+` with `:`, the registry URL is redacted in both stacks' errors, and the
Rust diagnostic codes now match pnpm's.

Closes pnpm/pnpm#13558.

---------

Co-authored-by: Zoltan Kochan <z@kochan.io>
2026-09-08 09:31:51 +02:00

103 lines
3.4 KiB
TypeScript

import fs from 'node:fs'
import path from 'node:path'
import { beforeEach, describe, expect, test } from '@jest/globals'
import { cache } from '@pnpm/cache.commands'
import { ABBREVIATED_META_DIR, FULL_FILTERED_META_DIR, FULL_META_DIR } from '@pnpm/constants'
import { prepare } from '@pnpm/prepare'
import { REGISTRY_MOCK_PORT } from '@pnpm/testing.registry-mock'
import { rimrafSync } from '@zkochan/rimraf'
import { safeExeca as execa } from 'execa'
const pnpmBin = path.join(import.meta.dirname, '../../../pnpm/bin/pnpm.mjs')
const REGISTRY = `http://localhost:${REGISTRY_MOCK_PORT}/`
describe('cache delete', () => {
let cacheDir: string
let storeDir: string
beforeEach(async () => {
prepare()
cacheDir = path.resolve('cache')
storeDir = path.resolve('store')
await execa('node', [
pnpmBin,
'add',
'is-negative@2.1.0',
`--store-dir=${storeDir}`,
`--cache-dir=${cacheDir}`,
'--config.resolution-mode=highest',
`--registry=${REGISTRY}`,
])
rimrafSync('node_modules')
rimrafSync('pnpm-lock.yaml')
await execa('node', [
pnpmBin,
'add',
'is-negative@2.1.0',
'is-positive@1.0.0',
`--store-dir=${storeDir}`,
`--cache-dir=${cacheDir}`,
'--config.resolution-mode=highest',
])
})
test('delete all metadata from the cache that matches a pattern', async () => {
await cache.handler({
cacheDir,
cliOptions: {},
pnpmHomeDir: storeDir,
}, ['delete', '*-positive'])
const result = await cache.handler({
cacheDir,
cliOptions: {},
pnpmHomeDir: storeDir,
}, ['list'])
expect(result).toBe(`http%3A+localhost+${REGISTRY_MOCK_PORT}/is-negative.jsonl
https%3A+registry.npmjs.org/is-negative.jsonl`)
})
})
describe('cache delete across metadata directories', () => {
test('deletes a package from every metadata cache directory, not only the one the current mode reads', async () => {
prepare()
const cacheDir = path.resolve('cache')
const registryName = 'https%3A+registry.npmjs.org'
const metaDirs = [ABBREVIATED_META_DIR, FULL_META_DIR, FULL_FILTERED_META_DIR]
const sentinel = (metaDir: string) => path.join(cacheDir, metaDir, registryName, '@vue', 'compiler-core.jsonl')
for (const metaDir of metaDirs) {
fs.mkdirSync(path.dirname(sentinel(metaDir)), { recursive: true })
fs.writeFileSync(sentinel(metaDir), '')
}
const result = await cache.handler({
cacheDir,
cliOptions: {},
pnpmHomeDir: cacheDir,
}, ['delete', '@vue/compiler-core'])
for (const metaDir of metaDirs) {
expect(fs.existsSync(sentinel(metaDir))).toBe(false)
}
expect(result).toBe(`${registryName}/@vue/compiler-core.jsonl`)
})
test('deletes metadata cached under a non-current mode even when the other directories are absent', async () => {
prepare()
const cacheDir = path.resolve('cache')
// The default mode reads `metadata`, but the package is only cached under
// `metadata-full-filtered` and the other directories don't exist.
const sentinel = path.join(cacheDir, FULL_FILTERED_META_DIR, 'https%3A+registry.npmjs.org', '@vue', 'compiler-core.jsonl')
fs.mkdirSync(path.dirname(sentinel), { recursive: true })
fs.writeFileSync(sentinel, '')
await cache.handler({
cacheDir,
cliOptions: {},
pnpmHomeDir: cacheDir,
}, ['delete', '@vue/compiler-core'])
expect(fs.existsSync(sentinel)).toBe(false)
})
})