github.com/anchore/syft@v1.38.2/syft/pkg/cataloger/javascript/parse_pnpm_lock.go (about)

     1  package javascript
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"io"
     7  	"sort"
     8  	"strconv"
     9  	"strings"
    10  
    11  	"go.yaml.in/yaml/v3"
    12  
    13  	"github.com/anchore/syft/internal/log"
    14  	"github.com/anchore/syft/internal/unknown"
    15  	"github.com/anchore/syft/syft/artifact"
    16  	"github.com/anchore/syft/syft/file"
    17  	"github.com/anchore/syft/syft/pkg"
    18  	"github.com/anchore/syft/syft/pkg/cataloger/generic"
    19  	"github.com/anchore/syft/syft/pkg/cataloger/internal/dependency"
    20  )
    21  
    22  // pnpmPackage holds the raw name and version extracted from the lockfile.
    23  type pnpmPackage struct {
    24  	Name         string
    25  	Version      string
    26  	Integrity    string
    27  	Dependencies map[string]string
    28  }
    29  
    30  // pnpmLockfileParser defines the interface for parsing different versions of pnpm lockfiles.
    31  type pnpmLockfileParser interface {
    32  	Parse(version float64, data []byte) ([]pnpmPackage, error)
    33  }
    34  
    35  type pnpmV6PackageEntry struct {
    36  	Resolution   map[string]string `yaml:"resolution"`
    37  	Dependencies map[string]string `yaml:"dependencies"`
    38  }
    39  
    40  // pnpmV6LockYaml represents the structure of pnpm lockfiles for versions < 9.0.
    41  type pnpmV6LockYaml struct {
    42  	Dependencies map[string]interface{}        `yaml:"dependencies"`
    43  	Packages     map[string]pnpmV6PackageEntry `yaml:"packages"`
    44  }
    45  
    46  type pnpmV9SnapshotEntry struct {
    47  	Dependencies               map[string]string `yaml:"dependencies"`
    48  	Optional                   bool              `yaml:"optional"`
    49  	OptionalDependencies       map[string]string `yaml:"optionalDependencies"`
    50  	TransitivePeerDependencies []string          `yaml:"transitivePeerDependencies"`
    51  }
    52  
    53  type pnpmV9PackageEntry struct {
    54  	Resolution       map[string]string `yaml:"resolution"`
    55  	PeerDependencies map[string]string `yaml:"peerDependencies"`
    56  }
    57  
    58  // pnpmV9LockYaml represents the structure of pnpm lockfiles for versions >= 9.0.
    59  type pnpmV9LockYaml struct {
    60  	LockfileVersion string                         `yaml:"lockfileVersion"`
    61  	Importers       map[string]interface{}         `yaml:"importers"` // Using interface{} for forward compatibility
    62  	Packages        map[string]pnpmV9PackageEntry  `yaml:"packages"`
    63  	Snapshots       map[string]pnpmV9SnapshotEntry `yaml:"snapshots"`
    64  }
    65  
    66  type genericPnpmLockAdapter struct {
    67  	cfg CatalogerConfig
    68  }
    69  
    70  func newGenericPnpmLockAdapter(cfg CatalogerConfig) genericPnpmLockAdapter {
    71  	return genericPnpmLockAdapter{
    72  		cfg: cfg,
    73  	}
    74  }
    75  
    76  // Parse implements the pnpmLockfileParser interface for v6-v8 lockfiles.
    77  func (p *pnpmV6LockYaml) Parse(version float64, data []byte) ([]pnpmPackage, error) {
    78  	if err := yaml.Unmarshal(data, p); err != nil {
    79  		return nil, fmt.Errorf("failed to unmarshal pnpm v6 lockfile: %w", err)
    80  	}
    81  
    82  	packages := make(map[string]pnpmPackage)
    83  
    84  	// Direct dependencies
    85  	for name, info := range p.Dependencies {
    86  		ver, err := parseVersionField(name, info)
    87  		if err != nil {
    88  			log.WithFields("package", name, "error", err).Trace("unable to parse pnpm dependency")
    89  			continue
    90  		}
    91  		key := name + "@" + ver
    92  		packages[key] = pnpmPackage{Name: name, Version: ver}
    93  	}
    94  
    95  	splitChar := "/"
    96  	if version >= 6.0 {
    97  		splitChar = "@"
    98  	}
    99  
   100  	// All transitive dependencies
   101  	for key, pkgInfo := range p.Packages {
   102  		name, ver, ok := parsePnpmPackageKey(key, splitChar)
   103  		if !ok {
   104  			log.WithFields("key", key).Trace("unable to parse pnpm package key")
   105  			continue
   106  		}
   107  		pkgKey := name + "@" + ver
   108  
   109  		integrity := ""
   110  		if value, ok := pkgInfo.Resolution["integrity"]; ok {
   111  			integrity = value
   112  		}
   113  
   114  		dependencies := make(map[string]string)
   115  		for depName, depVersion := range pkgInfo.Dependencies {
   116  			var normalizedVersion = strings.SplitN(depVersion, "(", 2)[0]
   117  			dependencies[depName] = normalizedVersion
   118  		}
   119  
   120  		packages[pkgKey] = pnpmPackage{Name: name, Version: ver, Integrity: integrity, Dependencies: dependencies}
   121  	}
   122  
   123  	return toSortedSlice(packages), nil
   124  }
   125  
   126  // Parse implements the PnpmLockfileParser interface for v9+ lockfiles.
   127  func (p *pnpmV9LockYaml) Parse(_ float64, data []byte) ([]pnpmPackage, error) {
   128  	if err := yaml.Unmarshal(data, p); err != nil {
   129  		return nil, fmt.Errorf("failed to unmarshal pnpm v9 lockfile: %w", err)
   130  	}
   131  
   132  	packages := make(map[string]pnpmPackage)
   133  
   134  	// In v9, all resolved dependencies are listed in the top-level "packages" field.
   135  	// The key format is like /<name>@<version> or /<name>@<version>(<peer-deps>).
   136  	for key, entry := range p.Packages {
   137  		// The separator for name and version is consistently '@' in v9+ keys.
   138  		name, ver, ok := parsePnpmPackageKey(key, "@")
   139  		if !ok {
   140  			log.WithFields("key", key).Trace("unable to parse pnpm v9 package key")
   141  			continue
   142  		}
   143  		pkgKey := name + "@" + ver
   144  		packages[pkgKey] = pnpmPackage{Name: name, Version: ver, Integrity: entry.Resolution["integrity"]}
   145  	}
   146  
   147  	for key, snapshotInfo := range p.Snapshots {
   148  		name, ver, ok := parsePnpmPackageKey(key, "@")
   149  		if !ok {
   150  			log.WithFields("key", key).Trace("unable to parse pnpm v9 package snapshot key")
   151  			continue
   152  		}
   153  		pkgKey := name + "@" + ver
   154  		if pkg, ok := packages[pkgKey]; ok {
   155  			pkg.Dependencies = make(map[string]string)
   156  			for name, versionSpecifier := range snapshotInfo.Dependencies {
   157  				var normalizedVersion = strings.SplitN(versionSpecifier, "(", 2)[0]
   158  				pkg.Dependencies[name] = normalizedVersion
   159  			}
   160  			packages[pkgKey] = pkg
   161  		} else {
   162  			log.WithFields("package", pkgKey).Trace("package not found in packages map")
   163  		}
   164  	}
   165  
   166  	return toSortedSlice(packages), nil
   167  }
   168  
   169  // newPnpmLockfileParser is a factory function that returns the correct parser for the given lockfile version.
   170  func newPnpmLockfileParser(version float64) pnpmLockfileParser {
   171  	if version >= 9.0 {
   172  		return &pnpmV9LockYaml{}
   173  	}
   174  	return &pnpmV6LockYaml{}
   175  }
   176  
   177  // parsePnpmLock is the main parser function for pnpm-lock.yaml files.
   178  func (a genericPnpmLockAdapter) parsePnpmLock(ctx context.Context, resolver file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
   179  	data, err := io.ReadAll(reader)
   180  	if err != nil {
   181  		return nil, nil, fmt.Errorf("failed to load pnpm-lock.yaml file: %w", err)
   182  	}
   183  
   184  	var lockfile struct {
   185  		Version string `yaml:"lockfileVersion"`
   186  	}
   187  	if err := yaml.Unmarshal(data, &lockfile); err != nil {
   188  		return nil, nil, fmt.Errorf("failed to parse pnpm-lock.yaml version: %w", err)
   189  	}
   190  
   191  	version, err := strconv.ParseFloat(lockfile.Version, 64)
   192  	if err != nil {
   193  		return nil, nil, fmt.Errorf("invalid lockfile version %q: %w", lockfile.Version, err)
   194  	}
   195  
   196  	parser := newPnpmLockfileParser(version)
   197  	pnpmPkgs, err := parser.Parse(version, data)
   198  	if err != nil {
   199  		return nil, nil, fmt.Errorf("failed to parse pnpm-lock.yaml file: %w", err)
   200  	}
   201  
   202  	packages := make([]pkg.Package, len(pnpmPkgs))
   203  	for i, p := range pnpmPkgs {
   204  		packages[i] = newPnpmPackage(ctx, a.cfg, resolver, reader.Location, p.Name, p.Version, p.Integrity, p.Dependencies)
   205  	}
   206  
   207  	return packages, dependency.Resolve(pnpmLockDependencySpecifier, packages), unknown.IfEmptyf(packages, "unable to determine packages")
   208  }
   209  
   210  // parseVersionField extracts the version string from a dependency entry.
   211  func parseVersionField(name string, info interface{}) (string, error) {
   212  	switch v := info.(type) {
   213  	case string:
   214  		return v, nil
   215  	case map[string]interface{}:
   216  		if ver, ok := v["version"].(string); ok {
   217  			// e.g., "1.2.3(react@17.0.0)" -> "1.2.3"
   218  			return strings.SplitN(ver, "(", 2)[0], nil
   219  		}
   220  		return "", fmt.Errorf("version field is not a string for %q", name)
   221  	default:
   222  		return "", fmt.Errorf("unsupported dependency type %T for %q", info, name)
   223  	}
   224  }
   225  
   226  // parsePnpmPackageKey extracts the package name and version from a lockfile package key.
   227  // Handles formats like:
   228  // - /@babel/runtime/7.16.7
   229  // - /@types/node@14.18.12
   230  // - /is-glob@4.0.3
   231  // - /@babel/helper-plugin-utils@7.24.7(@babel/core@7.24.7)
   232  func parsePnpmPackageKey(key, separator string) (name, version string, ok bool) {
   233  	// Strip peer dependency information, e.g., (...)
   234  	key = strings.SplitN(key, "(", 2)[0]
   235  
   236  	// Strip leading slash
   237  	key = strings.TrimPrefix(key, "/")
   238  
   239  	parts := strings.Split(key, separator)
   240  	if len(parts) < 2 {
   241  		return "", "", false
   242  	}
   243  
   244  	version = parts[len(parts)-1]
   245  	name = strings.Join(parts[:len(parts)-1], separator)
   246  
   247  	return name, version, true
   248  }
   249  
   250  // toSortedSlice converts the map of packages to a sorted slice for deterministic output.
   251  func toSortedSlice(packages map[string]pnpmPackage) []pnpmPackage {
   252  	pkgs := make([]pnpmPackage, 0, len(packages))
   253  	for _, p := range packages {
   254  		pkgs = append(pkgs, p)
   255  	}
   256  
   257  	sort.Slice(pkgs, func(i, j int) bool {
   258  		if pkgs[i].Name == pkgs[j].Name {
   259  			return pkgs[i].Version < pkgs[j].Version
   260  		}
   261  		return pkgs[i].Name < pkgs[j].Name
   262  	})
   263  
   264  	return pkgs
   265  }