diff --git a/syft/pkg/cataloger/javascript/parse_pnpm_lock.go b/syft/pkg/cataloger/javascript/parse_pnpm_lock.go index ddba32244..deb6a7e3e 100644 --- a/syft/pkg/cataloger/javascript/parse_pnpm_lock.go +++ b/syft/pkg/cataloger/javascript/parse_pnpm_lock.go @@ -1,9 +1,13 @@ package javascript import ( + "cmp" "context" "fmt" "io" + "iter" + "maps" + "slices" "sort" "strconv" "strings" @@ -84,8 +88,8 @@ func (p *pnpmV6LockYaml) Parse(version float64, data []byte) ([]pnpmPackage, err packages := make(map[string]pnpmPackage) - // Direct dependencies - for name, info := range p.Dependencies { + // Direct dependencies — use sorted keys for deterministic output + for name, info := range sortedIter(p.Dependencies) { ver, err := parseVersionField(name, info) if err != nil { log.WithFields("package", name, "error", err).Trace("unable to parse pnpm dependency") @@ -100,8 +104,8 @@ func (p *pnpmV6LockYaml) Parse(version float64, data []byte) ([]pnpmPackage, err splitChar = "@" } - // All transitive dependencies - for key, pkgInfo := range p.Packages { + // All transitive dependencies — use sorted keys for deterministic output + for key, pkgInfo := range sortedIter(p.Packages) { name, ver, ok := parsePnpmPackageKey(key, splitChar) if !ok { log.WithFields("key", key).Trace("unable to parse pnpm package key") @@ -115,7 +119,7 @@ func (p *pnpmV6LockYaml) Parse(version float64, data []byte) ([]pnpmPackage, err } dependencies := make(map[string]string) - for depName, depVersion := range pkgInfo.Dependencies { + for depName, depVersion := range sortedIter(pkgInfo.Dependencies) { var normalizedVersion = strings.SplitN(depVersion, "(", 2)[0] dependencies[depName] = normalizedVersion } @@ -136,7 +140,7 @@ func (p *pnpmV9LockYaml) Parse(_ float64, data []byte) ([]pnpmPackage, error) { // In v9, all resolved dependencies are listed in the top-level "packages" field. // The key format is like /@ or /@(). - for key, entry := range p.Packages { + for key, entry := range sortedIter(p.Packages) { // The separator for name and version is consistently '@' in v9+ keys. name, ver, ok := parsePnpmPackageKey(key, "@") if !ok { @@ -147,7 +151,7 @@ func (p *pnpmV9LockYaml) Parse(_ float64, data []byte) ([]pnpmPackage, error) { packages[pkgKey] = pnpmPackage{Name: name, Version: ver, Integrity: entry.Resolution["integrity"], Dev: entry.Dev} } - for key, snapshotInfo := range p.Snapshots { + for key, snapshotInfo := range sortedIter(p.Snapshots) { name, ver, ok := parsePnpmPackageKey(key, "@") if !ok { log.WithFields("key", key).Trace("unable to parse pnpm v9 package snapshot key") @@ -156,7 +160,7 @@ func (p *pnpmV9LockYaml) Parse(_ float64, data []byte) ([]pnpmPackage, error) { pkgKey := name + "@" + ver if pkg, ok := packages[pkgKey]; ok { pkg.Dependencies = make(map[string]string) - for name, versionSpecifier := range snapshotInfo.Dependencies { + for name, versionSpecifier := range sortedIter(snapshotInfo.Dependencies) { var normalizedVersion = strings.SplitN(versionSpecifier, "(", 2)[0] pkg.Dependencies[name] = normalizedVersion } @@ -253,6 +257,19 @@ func parsePnpmPackageKey(key, separator string) (name, version string, ok bool) return name, version, true } +// sortedIter returns an iterator over the map entries sorted by key, ensuring deterministic iteration order. +func sortedIter[K cmp.Ordered, V any](values map[K]V) iter.Seq2[K, V] { + return func(yield func(K, V) bool) { + keys := slices.Collect(maps.Keys(values)) + slices.Sort(keys) + for _, key := range keys { + if !yield(key, values[key]) { + return + } + } + } +} + // 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)) diff --git a/syft/pkg/cataloger/javascript/parse_pnpm_lock_test.go b/syft/pkg/cataloger/javascript/parse_pnpm_lock_test.go index 307375ade..b5a2f8173 100644 --- a/syft/pkg/cataloger/javascript/parse_pnpm_lock_test.go +++ b/syft/pkg/cataloger/javascript/parse_pnpm_lock_test.go @@ -8,6 +8,9 @@ import ( "os" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/anchore/syft/syft/artifact" "github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/pkg" @@ -524,6 +527,61 @@ func Test_corruptPnpmLock(t *testing.T) { TestParser(t, adapter.parsePnpmLock) } +func TestParsePnpmLock_DeterministicWithCollidingPeerDeps(t *testing.T) { + // this test verifies that when multiple lockfile keys collapse to the same + // package key after peer-dep stripping (e.g., pkg@1.0.0(peer-a@1) and + // pkg@1.0.0(peer-b@2) both become pkg@1.0.0), the output is deterministic. + // Since we iterate in sorted key order and later keys overwrite earlier ones, + // the last key lexicographically wins. + + // v9 lockfile with two entries that collapse to the same key + lockfileV9 := []byte(` +lockfileVersion: '9.0' +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} +snapshots: + some-pkg@1.0.0(peer-b@2.0.0): {} + some-pkg@1.0.0(peer-a@1.0.0): {} +`) + + // run multiple times to catch nondeterminism + for i := 0; i < 10; i++ { + parser := &pnpmV9LockYaml{} + pkgs, err := parser.Parse(9.0, lockfileV9) + require.NoError(t, err) + require.Len(t, pkgs, 1, "expected exactly one package after key collision") + + // sorted order: peer-a (AAA) then peer-b (BBB), so BBB overwrites AAA + 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") + } + + // v6 lockfile with two entries that collapse to the same key + lockfileV6 := []byte(` +lockfileVersion: '6.0' +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 i := 0; i < 10; i++ { + parser := &pnpmV6LockYaml{} + pkgs, err := parser.Parse(6.0, lockfileV6) + 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) { return func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK)