Skip to content

Commit 28d998d

Browse files
committed
test(pnpm): add processLockfile consistency tests and stabilize traversal order
1 parent 0ae12ea commit 28d998d

File tree

6 files changed

+199
-13
lines changed

6 files changed

+199
-13
lines changed

module/pnpm/process.go

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,17 @@ import (
44
"bytes"
55
"context"
66
"fmt"
7+
"io"
8+
"os"
9+
"path/filepath"
10+
"sort"
11+
712
"github.com/murphysecurity/murphysec/infra/logctx"
813
"github.com/murphysecurity/murphysec/model"
914
"github.com/murphysecurity/murphysec/module/pnpm/shared"
1015
v5 "github.com/murphysecurity/murphysec/module/pnpm/v5"
1116
v6 "github.com/murphysecurity/murphysec/module/pnpm/v6"
1217
v9 "github.com/murphysecurity/murphysec/module/pnpm/v9"
13-
"io"
14-
"os"
15-
"path/filepath"
1618
)
1719

1820
var EcoRepo = model.EcoRepo{
@@ -43,6 +45,11 @@ func processDir(ctx context.Context, dir string) (result processDirResult) {
4345
result.e = fmt.Errorf("reading %s failed: %w", LockfileName, e)
4446
return
4547
}
48+
processLockfile(ctx, data, &result)
49+
return
50+
}
51+
52+
func processLockfile(ctx context.Context, data []byte, result *processDirResult) {
4653
version, e := parseLockfileVersion(data)
4754
if e != nil {
4855
result.e = fmt.Errorf("parse lockfile version failed, %w", e)
@@ -71,7 +78,34 @@ func processDir(ctx context.Context, dir string) (result processDirResult) {
7178
result.e = fmt.Errorf("unsupported version \"%s\"", version)
7279
return
7380
}
74-
return
81+
normalizeTrees(result.trees)
82+
}
83+
84+
func normalizeTrees(trees []shared.DepTree) {
85+
for i := range trees {
86+
sortDepItems(trees[i].Dependencies)
87+
}
88+
sort.SliceStable(trees, func(i, j int) bool {
89+
return trees[i].Name < trees[j].Name
90+
})
91+
}
92+
93+
func sortDepItems(deps []model.DependencyItem) {
94+
for i := range deps {
95+
sortDepItems(deps[i].Dependencies)
96+
}
97+
sort.SliceStable(deps, func(i, j int) bool {
98+
if deps[i].CompName != deps[j].CompName {
99+
return deps[i].CompName < deps[j].CompName
100+
}
101+
if deps[i].CompVersion != deps[j].CompVersion {
102+
return deps[i].CompVersion < deps[j].CompVersion
103+
}
104+
if deps[i].IsOnline.Value != deps[j].IsOnline.Value {
105+
return !deps[i].IsOnline.Value && deps[j].IsOnline.Value
106+
}
107+
return deps[i].IsOnline.Valid && !deps[j].IsOnline.Valid
108+
})
75109
}
76110

77111
func processV5(ctx context.Context, data []byte) (trees []shared.DepTree, e error) {

module/pnpm/process_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package pnpm
2+
3+
import (
4+
"context"
5+
"embed"
6+
"encoding/json"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
)
11+
12+
//go:embed v5/testdata/*.yaml v6/testdata/*.yaml v9/testdata/*.yaml
13+
var processTestdata embed.FS
14+
15+
func TestProcessLockfile_ConsistentAcrossMultipleRuns(t *testing.T) {
16+
testCases := []struct {
17+
name string
18+
path string
19+
}{
20+
{name: "v5-1", path: "v5/testdata/1.yaml"},
21+
{name: "v5-5", path: "v5/testdata/5.yaml"},
22+
{name: "v6-1", path: "v6/testdata/1.yaml"},
23+
{name: "v6-3", path: "v6/testdata/3.yaml"},
24+
{name: "v9-1", path: "v9/testdata/1.yaml"},
25+
{name: "v9-9", path: "v9/testdata/9.yaml"},
26+
}
27+
28+
for _, tc := range testCases {
29+
t.Run(tc.name, func(t *testing.T) {
30+
data, e := processTestdata.ReadFile(tc.path)
31+
assert.NoError(t, e)
32+
33+
var baseline []byte
34+
for i := 0; i < 10; i++ {
35+
var result processDirResult
36+
processLockfile(context.Background(), data, &result)
37+
assert.NoError(t, result.e)
38+
39+
got, e := json.Marshal(result.trees)
40+
assert.NoError(t, e)
41+
if i == 0 {
42+
baseline = got
43+
continue
44+
}
45+
assert.Equal(t, string(baseline), string(got), "process result changed at run %d", i+1)
46+
}
47+
})
48+
}
49+
}

module/pnpm/v5/v5.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package v5
33
import (
44
"github.com/murphysecurity/murphysec/model"
55
"github.com/murphysecurity/murphysec/module/pnpm/shared"
6+
"sort"
67
"strings"
78
)
89

@@ -50,7 +51,13 @@ func nextCallVisitor[T any](l *Lockfile, parent *shared.GComponent, m map[string
5051
}
5152

5253
func _visit[T any](l *Lockfile, parent *shared.GComponent, m map[string]string, cd *circleDetector, visitor shared.GVisitor[T], arg T) error {
53-
for n, v := range m {
54+
keys := make([]string, 0, len(m))
55+
for n := range m {
56+
keys = append(keys, n)
57+
}
58+
sort.Strings(keys)
59+
for _, n := range keys {
60+
v := m[n]
5461
var pkg = l.findPkg(n, v)
5562
if pkg == nil {
5663
continue

module/pnpm/v5/v5_test.go

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ package v5
22

33
import (
44
"encoding/json"
5-
"github.com/murphysecurity/murphysec/utils/must"
6-
"github.com/stretchr/testify/assert"
75
"io"
86
"testing"
7+
8+
"github.com/murphysecurity/murphysec/utils/must"
9+
"github.com/stretchr/testify/assert"
910
)
1011

1112
func TestLockfile(t *testing.T) {
@@ -33,3 +34,61 @@ func TestBuildDepTree(t *testing.T) {
3334
assert.NotNil(t, tree)
3435
t.Log(string(must.A(json.MarshalIndent(tree, "| ", " "))))
3536
}
37+
38+
func TestParseLockfile_ConsistentAcrossMultipleRuns(t *testing.T) {
39+
files, _ := testFiles.ReadDir("testdata")
40+
assert.NotEmpty(t, files)
41+
for _, s := range files {
42+
t.Run(s.Name(), func(t *testing.T) {
43+
f, e := testFiles.Open("testdata/" + s.Name())
44+
assert.NoError(t, e)
45+
defer func() { assert.NoError(t, f.Close()) }()
46+
data, e := io.ReadAll(f)
47+
assert.NoError(t, e)
48+
49+
var baseline []byte
50+
for i := 0; i < 10; i++ {
51+
lockfile, e := ParseLockfile(data)
52+
assert.NoError(t, e)
53+
assert.NotNil(t, lockfile)
54+
got, e := json.Marshal(lockfile)
55+
assert.NoError(t, e)
56+
if i == 0 {
57+
baseline = got
58+
continue
59+
}
60+
assert.Equal(t, string(baseline), string(got), "parse result changed at run %d", i+1)
61+
}
62+
})
63+
}
64+
}
65+
66+
func TestBuildDepTree_AllTestdata(t *testing.T) {
67+
files, _ := testFiles.ReadDir("testdata")
68+
assert.NotEmpty(t, files)
69+
for _, s := range files {
70+
t.Run(s.Name(), func(t *testing.T) {
71+
f, e := testFiles.Open("testdata/" + s.Name())
72+
assert.NoError(t, e)
73+
defer func() { assert.NoError(t, f.Close()) }()
74+
data, e := io.ReadAll(f)
75+
assert.NoError(t, e)
76+
77+
lockfile, e := ParseLockfile(data)
78+
assert.NoError(t, e)
79+
assert.NotNil(t, lockfile)
80+
81+
tree := BuildDepTree(lockfile, nil, "")
82+
if len(lockfile.Dependencies) > 0 || len(lockfile.DevDependencies) > 0 {
83+
assert.NotNil(t, tree)
84+
}
85+
86+
for importerName, importer := range lockfile.Importers {
87+
importerTree := BuildDepTree(lockfile, importer, importerName)
88+
if importerTree != nil {
89+
assert.Equal(t, importerName, importerTree.Name)
90+
}
91+
}
92+
})
93+
}
94+
}

module/pnpm/v6/parser.go

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"github.com/murphysecurity/murphysec/model"
77
"github.com/murphysecurity/murphysec/module/pnpm/shared"
88
"iter"
9+
"sort"
910
"strings"
1011
)
1112

@@ -59,7 +60,13 @@ func Process(_ctx context.Context, data []byte, strict bool) ([]shared.DepTree,
5960
Prune: make(map[[2]string]struct{}),
6061
Dev: dev,
6162
}
62-
for name, it := range m {
63+
keys := make([]string, 0, len(m))
64+
for name := range m {
65+
keys = append(keys, name)
66+
}
67+
sort.Strings(keys)
68+
for _, name := range keys {
69+
it := m[name]
6370
var dep model.DependencyItem
6471
e := _visit(ctx, name, it.Version, "", &dep)
6572
if e != nil {
@@ -82,7 +89,13 @@ func Process(_ctx context.Context, data []byte, strict bool) ([]shared.DepTree,
8289
return []shared.DepTree{root}, nil
8390
} else {
8491
var r []shared.DepTree
85-
for relPath, importer := range lockfile.Importers {
92+
importerKeys := make([]string, 0, len(lockfile.Importers))
93+
for relPath := range lockfile.Importers {
94+
importerKeys = append(importerKeys, relPath)
95+
}
96+
sort.Strings(importerKeys)
97+
for _, relPath := range importerKeys {
98+
importer := lockfile.Importers[relPath]
8699
var tree shared.DepTree
87100
tree.Name = relPath
88101
if e := f(importer.Dependencies, false, &tree); e != nil {
@@ -126,7 +139,13 @@ func _visit(ctx *visitContext, name, version, path string, node *model.Dependenc
126139
}
127140
if !cDetected {
128141
var f = func(m map[string]string) error {
129-
for n, v := range m {
142+
keys := make([]string, 0, len(m))
143+
for n := range m {
144+
keys = append(keys, n)
145+
}
146+
sort.Strings(keys)
147+
for _, n := range keys {
148+
v := m[n]
130149
node.Dependencies = append(node.Dependencies, model.DependencyItem{})
131150
var key = key + "/" + n + "/" + v
132151
var e = _visit(ctx, n, v, key, &node.Dependencies[len(node.Dependencies)-1])

module/pnpm/v9/parse.go

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,13 @@ func Parse(ctx context.Context, reader io.Reader) (trees []shared.DepTree, e err
3838
if e != nil {
3939
return
4040
}
41-
for path, importer := range doc.Importers {
41+
importerPaths := make([]string, 0, len(doc.Importers))
42+
for path := range doc.Importers {
43+
importerPaths = append(importerPaths, path)
44+
}
45+
sort.Strings(importerPaths)
46+
for _, path := range importerPaths {
47+
importer := doc.Importers[path]
4248
var c = importerHandlingCtx{
4349
Logger: logctx.Use(ctx).Sugar(),
4450
Snapshot: doc.Snapshots,
@@ -47,7 +53,13 @@ func Parse(ctx context.Context, reader io.Reader) (trees []shared.DepTree, e err
4753
CircularPath: make([][2]string, 0),
4854
}
4955
var deps []model.DependencyItem
50-
for name, obj := range importer.Dependencies {
56+
depNames := make([]string, 0, len(importer.Dependencies))
57+
for name := range importer.Dependencies {
58+
depNames = append(depNames, name)
59+
}
60+
sort.Strings(depNames)
61+
for _, name := range depNames {
62+
obj := importer.Dependencies[name]
5163
var r model.DependencyItem
5264
r, e = c.handle(name, obj.Version, true)
5365
if e != nil {
@@ -58,7 +70,13 @@ func Parse(ctx context.Context, reader io.Reader) (trees []shared.DepTree, e err
5870
c.Handled = make(map[[2]string]struct{})
5971
c.CircularDetectMap = make(map[[2]string]struct{})
6072
c.CircularPath = make([][2]string, 0)
61-
for name, obj := range importer.DevDependencies {
73+
devDepNames := make([]string, 0, len(importer.DevDependencies))
74+
for name := range importer.DevDependencies {
75+
devDepNames = append(devDepNames, name)
76+
}
77+
sort.Strings(devDepNames)
78+
for _, name := range devDepNames {
79+
obj := importer.DevDependencies[name]
6280
var r model.DependencyItem
6381
r, e = c.handle(name, obj.Version, false)
6482
if e != nil {

0 commit comments

Comments
 (0)