mirror of
https://github.com/anchore/syft.git
synced 2025-11-17 08:23:15 +01:00
feat: Parse pnpm v9 lockfiles (#4256)
Signed-off-by: bernardoamc <bernardo.amc@gmail.com>
This commit is contained in:
parent
3b82a3724a
commit
231f04ae0e
@ -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
|
||||
}
|
||||
|
||||
@ -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").
|
||||
|
||||
31
syft/pkg/cataloger/javascript/test-fixtures/pnpm-v9/pnpm-lock.yaml
generated
Normal file
31
syft/pkg/cataloger/javascript/test-fixtures/pnpm-v9/pnpm-lock.yaml
generated
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user