feat: Parse pnpm v9 lockfiles (#4256)

Signed-off-by: bernardoamc <bernardo.amc@gmail.com>
This commit is contained in:
Bernardo de Araujo 2025-10-09 15:07:59 -04:00 committed by GitHub
parent 3b82a3724a
commit 231f04ae0e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 239 additions and 70 deletions

View File

@ -4,7 +4,7 @@ import (
"context"
"fmt"
"io"
"regexp"
"sort"
"strconv"
"strings"
@ -21,92 +21,186 @@ import (
// integrity check
var _ generic.Parser = parsePnpmLock
type pnpmLockYaml struct {
Version string `json:"lockfileVersion" yaml:"lockfileVersion"`
Dependencies map[string]interface{} `json:"dependencies" yaml:"dependencies"`
Packages map[string]interface{} `json:"packages" yaml:"packages"`
// pnpmPackage holds the raw name and version extracted from the lockfile.
type pnpmPackage struct {
Name string
Version string
}
// pnpmLockfileParser defines the interface for parsing different versions of pnpm lockfiles.
type pnpmLockfileParser interface {
Parse(version float64, data []byte) ([]pnpmPackage, error)
}
// pnpmV6LockYaml represents the structure of pnpm lockfiles for versions < 9.0.
type pnpmV6LockYaml struct {
Dependencies map[string]interface{} `yaml:"dependencies"`
Packages map[string]interface{} `yaml:"packages"`
}
// pnpmV9LockYaml represents the structure of pnpm lockfiles for versions >= 9.0.
type pnpmV9LockYaml struct {
LockfileVersion string `yaml:"lockfileVersion"`
Importers map[string]interface{} `yaml:"importers"` // Using interface{} for forward compatibility
Packages map[string]interface{} `yaml:"packages"`
}
// Parse implements the pnpmLockfileParser interface for v6-v8 lockfiles.
func (p *pnpmV6LockYaml) Parse(version float64, data []byte) ([]pnpmPackage, error) {
if err := yaml.Unmarshal(data, p); err != nil {
return nil, fmt.Errorf("failed to unmarshal pnpm v6 lockfile: %w", err)
}
packages := make(map[string]pnpmPackage)
// Direct dependencies
for name, info := range p.Dependencies {
ver, err := parseVersionField(name, info)
if err != nil {
log.WithFields("package", name, "error", err).Trace("unable to parse pnpm dependency")
continue
}
key := name + "@" + ver
packages[key] = pnpmPackage{Name: name, Version: ver}
}
splitChar := "/"
if version >= 6.0 {
splitChar = "@"
}
// All transitive dependencies
for key := range p.Packages {
name, ver, ok := parsePnpmPackageKey(key, splitChar)
if !ok {
log.WithFields("key", key).Trace("unable to parse pnpm package key")
continue
}
pkgKey := name + "@" + ver
packages[pkgKey] = pnpmPackage{Name: name, Version: ver}
}
return toSortedSlice(packages), nil
}
// Parse implements the PnpmLockfileParser interface for v9+ lockfiles.
func (p *pnpmV9LockYaml) Parse(_ float64, data []byte) ([]pnpmPackage, error) {
if err := yaml.Unmarshal(data, p); err != nil {
return nil, fmt.Errorf("failed to unmarshal pnpm v9 lockfile: %w", err)
}
packages := make(map[string]pnpmPackage)
// In v9, all resolved dependencies are listed in the top-level "packages" field.
// The key format is like /<name>@<version> or /<name>@<version>(<peer-deps>).
for key := range p.Packages {
// The separator for name and version is consistently '@' in v9+ keys.
name, ver, ok := parsePnpmPackageKey(key, "@")
if !ok {
log.WithFields("key", key).Trace("unable to parse pnpm v9 package key")
continue
}
pkgKey := name + "@" + ver
packages[pkgKey] = pnpmPackage{Name: name, Version: ver}
}
return toSortedSlice(packages), nil
}
// newPnpmLockfileParser is a factory function that returns the correct parser for the given lockfile version.
func newPnpmLockfileParser(version float64) pnpmLockfileParser {
if version >= 9.0 {
return &pnpmV9LockYaml{}
}
return &pnpmV6LockYaml{}
}
// parsePnpmLock is the main parser function for pnpm-lock.yaml files.
func parsePnpmLock(ctx context.Context, resolver file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
bytes, err := io.ReadAll(reader)
data, err := io.ReadAll(reader)
if err != nil {
return nil, nil, fmt.Errorf("failed to load pnpm-lock.yaml file: %w", err)
}
var pkgs []pkg.Package
var lockFile pnpmLockYaml
var lockfile struct {
Version string `yaml:"lockfileVersion"`
}
if err := yaml.Unmarshal(data, &lockfile); err != nil {
return nil, nil, fmt.Errorf("failed to parse pnpm-lock.yaml version: %w", err)
}
if err := yaml.Unmarshal(bytes, &lockFile); err != nil {
version, err := strconv.ParseFloat(lockfile.Version, 64)
if err != nil {
return nil, nil, fmt.Errorf("invalid lockfile version %q: %w", lockfile.Version, err)
}
parser := newPnpmLockfileParser(version)
pnpmPkgs, err := parser.Parse(version, data)
if err != nil {
return nil, nil, fmt.Errorf("failed to parse pnpm-lock.yaml file: %w", err)
}
lockVersion, _ := strconv.ParseFloat(lockFile.Version, 64)
packages := make([]pkg.Package, len(pnpmPkgs))
for i, p := range pnpmPkgs {
packages[i] = newPnpmPackage(ctx, resolver, reader.Location, p.Name, p.Version)
}
for name, info := range lockFile.Dependencies {
version := ""
return packages, nil, unknown.IfEmptyf(packages, "unable to determine packages")
}
switch info := info.(type) {
// parseVersionField extracts the version string from a dependency entry.
func parseVersionField(name string, info interface{}) (string, error) {
switch v := info.(type) {
case string:
version = info
return v, nil
case map[string]interface{}:
v, ok := info["version"]
if !ok {
break
}
ver, ok := v.(string)
if ok {
version = parseVersion(ver)
if ver, ok := v["version"].(string); ok {
// e.g., "1.2.3(react@17.0.0)" -> "1.2.3"
return strings.SplitN(ver, "(", 2)[0], nil
}
return "", fmt.Errorf("version field is not a string for %q", name)
default:
log.Tracef("unsupported pnpm dependency type: %+v", info)
continue
}
if hasPkg(pkgs, name, version) {
continue
}
pkgs = append(pkgs, newPnpmPackage(ctx, resolver, reader.Location, name, version))
}
packageNameRegex := regexp.MustCompile(`^/?([^(]*)(?:\(.*\))*$`)
splitChar := "/"
if lockVersion >= 6.0 {
splitChar = "@"
}
// parse packages from packages section of pnpm-lock.yaml
for nameVersion := range lockFile.Packages {
nameVersion = packageNameRegex.ReplaceAllString(nameVersion, "$1")
nameVersionSplit := strings.Split(strings.TrimPrefix(nameVersion, "/"), splitChar)
// last element in split array is version
version := nameVersionSplit[len(nameVersionSplit)-1]
// construct name from all array items other than last item (version)
name := strings.Join(nameVersionSplit[:len(nameVersionSplit)-1], splitChar)
if hasPkg(pkgs, name, version) {
continue
}
pkgs = append(pkgs, newPnpmPackage(ctx, resolver, reader.Location, name, version))
}
pkg.Sort(pkgs)
return pkgs, nil, unknown.IfEmptyf(pkgs, "unable to determine packages")
}
func hasPkg(pkgs []pkg.Package, name, version string) bool {
for _, p := range pkgs {
if p.Name == name && p.Version == version {
return true
return "", fmt.Errorf("unsupported dependency type %T for %q", info, name)
}
}
return false
// parsePnpmPackageKey extracts the package name and version from a lockfile package key.
// Handles formats like:
// - /@babel/runtime/7.16.7
// - /@types/node@14.18.12
// - /is-glob@4.0.3
// - /@babel/helper-plugin-utils@7.24.7(@babel/core@7.24.7)
func parsePnpmPackageKey(key, separator string) (name, version string, ok bool) {
// Strip peer dependency information, e.g., (...)
key = strings.SplitN(key, "(", 2)[0]
// Strip leading slash
key = strings.TrimPrefix(key, "/")
parts := strings.Split(key, separator)
if len(parts) < 2 {
return "", "", false
}
func parseVersion(version string) string {
return strings.SplitN(version, "(", 2)[0]
version = parts[len(parts)-1]
name = strings.Join(parts[:len(parts)-1], separator)
return name, version, true
}
// toSortedSlice converts the map of packages to a sorted slice for deterministic output.
func toSortedSlice(packages map[string]pnpmPackage) []pnpmPackage {
pkgs := make([]pnpmPackage, 0, len(packages))
for _, p := range packages {
pkgs = append(pkgs, p)
}
sort.Slice(pkgs, func(i, j int) bool {
if pkgs[i].Name == pkgs[j].Name {
return pkgs[i].Version < pkgs[j].Version
}
return pkgs[i].Name < pkgs[j].Name
})
return pkgs
}

View File

@ -145,6 +145,50 @@ func TestParsePnpmV6Lock(t *testing.T) {
pkgtest.TestFileParser(t, fixture, parsePnpmLock, expectedPkgs, expectedRelationships)
}
func TestParsePnpmLockV9(t *testing.T) {
var expectedRelationships []artifact.Relationship
fixture := "test-fixtures/pnpm-v9/pnpm-lock.yaml"
locationSet := file.NewLocationSet(file.NewLocation(fixture))
expected := []pkg.Package{
{
Name: "@babel/core",
Version: "7.24.7",
PURL: "pkg:npm/%40babel/core@7.24.7",
Locations: locationSet,
Language: pkg.JavaScript,
Type: pkg.NpmPkg,
},
{
Name: "@babel/helper-plugin-utils",
Version: "7.24.7",
PURL: "pkg:npm/%40babel/helper-plugin-utils@7.24.7",
Locations: locationSet,
Language: pkg.JavaScript,
Type: pkg.NpmPkg,
},
{
Name: "is-positive",
Version: "3.1.0",
PURL: "pkg:npm/is-positive@3.1.0",
Locations: locationSet,
Language: pkg.JavaScript,
Type: pkg.NpmPkg,
},
{
Name: "rollup",
Version: "4.18.0",
PURL: "pkg:npm/rollup@4.18.0",
Locations: locationSet,
Language: pkg.JavaScript,
Type: pkg.NpmPkg,
},
}
// TODO: no relationships are under test
pkgtest.TestFileParser(t, fixture, parsePnpmLock, expected, expectedRelationships)
}
func Test_corruptPnpmLock(t *testing.T) {
pkgtest.NewCatalogTester().
FromFile(t, "test-fixtures/corrupt/pnpm-lock.yaml").

View File

@ -0,0 +1,31 @@
lockfileVersion: '9.0'
importers:
.:
dependencies:
is-positive:
specifier: ^3.1.0
version: 3.1.0
rollup:
specifier: ^4.18.0
version: 4.18.0
packages:
/@babel/core@7.24.7:
resolution: {integrity: sha512-4RjkiFFI42+268iBv2nC+iMLTJGQW3u9P7YvA3x/6MDrJ9IYZ8I/xx5a2GIhY5gBTOcI4iC5S5in2fGjE+P4Yw==}
dev: false
/@babel/helper-plugin-utils@7.24.7(@babel/core@7.24.7):
resolution: {integrity: sha512-8A2+zKm53/3w4rwbX11FMW/yFS6c5Vam02P/dw01aK6KbwkKqBaIt3eEATiKtn9I2uS1itk8/aZ2yZ/kURee4Q==}
peerDependencies:
'@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0
dev: false
/is-positive@3.1.0:
resolution: {integrity: sha512-9ffLCf_f5sopimAhg2g91a7b9Rw5A1aA9eI6S391S3VEzYw99I3iKjcZGxLp25s0cRxNBV5aL2mhn7421SSlA==}
dev: false
/rollup@4.18.0:
resolution: {integrity: sha512-QpQY2Q5i0y0Q3RoAvoChE/R5iN2k05N//bNvQbC2XvRjHFT1qWJ2r3n1bNqE+gGRJaeuQf0BxE42D7CyuLh3ZQ==}
hasBin: true
dev: false