fix(javascript): strip underscore peer-dep suffixes from pnpm v5 lockfile versions (#5175)

pnpm v5 lockfiles encode resolved peer dependencies as an
underscore-delimited suffix of the dependency path, e.g.
/acorn-jsx/5.3.2_acorn@8.8.0 or a hashed form like
/webpack-cli/4.10.0_fzn43tb6bdtdxy2s3aqevve2su. Only the v6+
parenthesized form was stripped, so for v5 lockfiles the suffix leaked
into reported versions and PURLs, and peer variants of the same
name+version were not deduplicated.

Strip the suffix on the v5 code path (version < 6.0) wherever a version
is extracted: top-level dependencies values, packages keys, and
per-package dependencies map values. Only values with a leading digit
are stripped so link:/file:/git specifiers are unaffected.

Fixes #5174

Signed-off-by: codeAnqiang-ma <273298913+codeAnqiang-ma@users.noreply.github.com>
Co-authored-by: codeAnqiang-ma <273298913+codeAnqiang-ma@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
anchor 2026-08-20 20:40:18 +08:00 committed by GitHub
parent 5c6cf08a8c
commit 78f39ee3a5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 135 additions and 0 deletions

View File

@ -89,6 +89,8 @@ func (p *pnpmV6LockYaml) Parse(version float64, doc *yaml.Node) ([]pnpmPackage,
return nil, fmt.Errorf("failed to unmarshal pnpm v6 lockfile: %w", err) return nil, fmt.Errorf("failed to unmarshal pnpm v6 lockfile: %w", err)
} }
isV5 := version < 6.0
packages := make(map[string]pnpmPackage) packages := make(map[string]pnpmPackage)
// Direct dependencies — use sorted keys for deterministic output // Direct dependencies — use sorted keys for deterministic output
@ -98,6 +100,9 @@ func (p *pnpmV6LockYaml) Parse(version float64, doc *yaml.Node) ([]pnpmPackage,
log.WithFields("package", name, "error", err).Trace("unable to parse pnpm dependency") log.WithFields("package", name, "error", err).Trace("unable to parse pnpm dependency")
continue continue
} }
if isV5 {
ver = stripPnpmV5PeerSuffix(ver)
}
key := name + "@" + ver key := name + "@" + ver
packages[key] = pnpmPackage{Name: name, Version: ver} packages[key] = pnpmPackage{Name: name, Version: ver}
} }
@ -114,6 +119,9 @@ func (p *pnpmV6LockYaml) Parse(version float64, doc *yaml.Node) ([]pnpmPackage,
log.WithFields("key", key).Trace("unable to parse pnpm package key") log.WithFields("key", key).Trace("unable to parse pnpm package key")
continue continue
} }
if isV5 {
ver = stripPnpmV5PeerSuffix(ver)
}
pkgKey := name + "@" + ver pkgKey := name + "@" + ver
integrity := "" integrity := ""
@ -124,6 +132,9 @@ func (p *pnpmV6LockYaml) Parse(version float64, doc *yaml.Node) ([]pnpmPackage,
dependencies := make(map[string]string) dependencies := make(map[string]string)
for depName, depVersion := range sortedIter(pkgInfo.Dependencies) { for depName, depVersion := range sortedIter(pkgInfo.Dependencies) {
var normalizedVersion = strings.SplitN(depVersion, "(", 2)[0] var normalizedVersion = strings.SplitN(depVersion, "(", 2)[0]
if isV5 {
normalizedVersion = stripPnpmV5PeerSuffix(normalizedVersion)
}
dependencies[depName] = normalizedVersion dependencies[depName] = normalizedVersion
} }
@ -315,6 +326,22 @@ func parseVersionField(name string, info any) (string, error) {
} }
} }
// stripPnpmV5PeerSuffix removes the underscore-delimited peer dependency suffix used by
// pnpm v5 lockfiles, e.g. "5.3.2_acorn@8.8.0" or "4.10.0_fzn43tb6bdtdxy2s3aqevve2su" -> "5.3.2" / "4.10.0".
// Lockfile v6+ encodes the same information in parentheses, which is stripped separately.
// Only values that look like a registry version (leading digit) are stripped, so that
// link:/file:/git specifiers are left untouched.
func stripPnpmV5PeerSuffix(version string) string {
idx := strings.Index(version, "_")
if idx <= 0 {
return version
}
if version[0] < '0' || version[0] > '9' {
return version
}
return version[:idx]
}
// parsePnpmPackageKey extracts the package name and version from a lockfile package key. // parsePnpmPackageKey extracts the package name and version from a lockfile package key.
// Handles formats like: // Handles formats like:
// - /@babel/runtime/7.16.7 // - /@babel/runtime/7.16.7

View File

@ -310,6 +310,66 @@ func TestParsePnpmV6Lock(t *testing.T) {
pkgtest.TestFileParser(t, fixture, adapter.parsePnpmLock, expectedPkgs, expectedRelationships) pkgtest.TestFileParser(t, fixture, adapter.parsePnpmLock, expectedPkgs, expectedRelationships)
} }
func TestParsePnpmLockV5PeerSuffix(t *testing.T) {
// pnpm v5 lockfiles encode resolved peer dependencies as an underscore-delimited
// suffix of the dependency path, either readable (e.g. "5.3.2_acorn@8.8.0") or
// hashed (e.g. "4.10.0_fzn43tb6bdtdxy2s3aqevve2su"). The suffix must be stripped
// from reported versions, just like the parenthesized form in v6+ lockfiles.
fixture := "testdata/pnpm-v5-peer-suffix/pnpm-lock.yaml"
locationSet := file.NewLocationSet(file.NewLocation(fixture))
expectedPkgs := []pkg.Package{
{
Name: "acorn",
Version: "8.8.0",
PURL: "pkg:npm/acorn@8.8.0",
Locations: locationSet,
Language: pkg.JavaScript,
Type: pkg.NpmPkg,
Metadata: pkg.PnpmLockEntry{
Resolution: pkg.PnpmLockResolution{Integrity: "sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w=="},
Dependencies: map[string]string{},
},
},
{
Name: "acorn-jsx",
Version: "5.3.2",
PURL: "pkg:npm/acorn-jsx@5.3.2",
Locations: locationSet,
Language: pkg.JavaScript,
Type: pkg.NpmPkg,
Metadata: pkg.PnpmLockEntry{
Resolution: pkg.PnpmLockResolution{Integrity: "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="},
Dependencies: map[string]string{
"acorn": "8.8.0",
},
},
},
{
Name: "webpack-cli",
Version: "4.10.0",
PURL: "pkg:npm/webpack-cli@4.10.0",
Locations: locationSet,
Language: pkg.JavaScript,
Type: pkg.NpmPkg,
Metadata: pkg.PnpmLockEntry{
Resolution: pkg.PnpmLockResolution{Integrity: "sha512-NLhDfH/h4O6UOy+0LSso42xvYypClINuMNBVVzX4vX98TmTaTUxwRbXdhucbFMd2qLaCTcLq/PdYrvi8onw90w=="},
Dependencies: map[string]string{},
},
},
}
expectedRelationships := []artifact.Relationship{
{
From: expectedPkgs[0],
To: expectedPkgs[1],
Type: artifact.DependencyOfRelationship,
},
}
adapter := newGenericPnpmLockAdapter(CatalogerConfig{})
pkgtest.TestFileParser(t, fixture, adapter.parsePnpmLock, expectedPkgs, expectedRelationships)
}
func TestParsePnpmLockV9(t *testing.T) { func TestParsePnpmLockV9(t *testing.T) {
var expectedRelationships []artifact.Relationship var expectedRelationships []artifact.Relationship
fixture := "testdata/pnpm-v9/pnpm-lock.yaml" fixture := "testdata/pnpm-v9/pnpm-lock.yaml"
@ -592,6 +652,27 @@ packages:
assert.Equal(t, "1.0.0", pkgs[0].Version) assert.Equal(t, "1.0.0", pkgs[0].Version)
assert.Equal(t, "sha512-BBB", pkgs[0].Integrity, "expected last lexicographic key to win") assert.Equal(t, "sha512-BBB", pkgs[0].Integrity, "expected last lexicographic key to win")
} }
// v5 lockfile with two entries that collapse to the same key (underscore peer-dep suffixes)
lockfileV5 := []byte(`
lockfileVersion: 5.4
packages:
/some-pkg/1.0.0_peer-b@2.0.0:
resolution: {integrity: sha512-BBB}
/some-pkg/1.0.0_peer-a@1.0.0:
resolution: {integrity: sha512-AAA}
`)
for range 10 {
parser := &pnpmV6LockYaml{}
pkgs, err := parser.Parse(5.4, lockfileV5)
require.NoError(t, err)
require.Len(t, pkgs, 1, "expected exactly one package after key collision")
assert.Equal(t, "some-pkg", pkgs[0].Name)
assert.Equal(t, "1.0.0", pkgs[0].Version)
assert.Equal(t, "sha512-BBB", pkgs[0].Integrity, "expected last lexicographic key to win")
}
} }
func generateMockNpmRegistryHandler(responseFixture string) func(w http.ResponseWriter, r *http.Request) { func generateMockNpmRegistryHandler(responseFixture string) func(w http.ResponseWriter, r *http.Request) {

View File

@ -0,0 +1,27 @@
lockfileVersion: 5.4
specifiers:
acorn: ^8.8.0
acorn-jsx: ^5.3.2
webpack-cli: ^4.10.0
dependencies:
acorn: 8.8.0
acorn-jsx: 5.3.2_acorn@8.8.0
webpack-cli: 4.10.0_fzn43tb6bdtdxy2s3aqevve2su
packages:
/acorn/8.8.0:
resolution: {integrity: sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==}
engines: {node: '>=0.4.0'}
hasBin: true
/acorn-jsx/5.3.2_acorn@8.8.0:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
dependencies:
acorn: 8.8.0
/webpack-cli/4.10.0_fzn43tb6bdtdxy2s3aqevve2su:
resolution: {integrity: sha512-NLhDfH/h4O6UOy+0LSso42xvYypClINuMNBVVzX4vX98TmTaTUxwRbXdhucbFMd2qLaCTcLq/PdYrvi8onw90w==}