github.com/google/osv-scalibr@v0.4.1/guidedremediation/internal/resolution/dependency_subgraph_test.go (about)

     1  // Copyright 2025 Google LLC
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package resolution_test
    16  
    17  import (
    18  	"cmp"
    19  	"maps"
    20  	"slices"
    21  	"testing"
    22  
    23  	"deps.dev/util/resolve"
    24  	"deps.dev/util/resolve/schema"
    25  	gocmp "github.com/google/go-cmp/cmp"
    26  	"github.com/google/osv-scalibr/guidedremediation/internal/manifest"
    27  	"github.com/google/osv-scalibr/guidedremediation/internal/manifest/npm"
    28  	"github.com/google/osv-scalibr/guidedremediation/internal/resolution"
    29  	osvpb "github.com/ossf/osv-schema/bindings/go/osvschema"
    30  )
    31  
    32  func TestDependencySubgraph(t *testing.T) {
    33  	g, err := schema.ParseResolve(`
    34  a 0.0.1
    35  	b@^1.0.1 1.0.1
    36  		$c@^1.0.0
    37  		d: d@^2.2.2 2.2.2
    38  	c: c@^1.0.2 1.0.2
    39  		e@1.0.0 1.0.0
    40  			$d@^2.0.0
    41  	f@^1.1.1 1.1.1
    42  		$c@^1.0.1
    43  		g@^2.2.2 2.2.2
    44  			h@^3.3.3 3.3.3
    45  				$d@^2.2.0
    46  `, resolve.NPM)
    47  	if err != nil {
    48  		t.Fatalf("failed to parse test graph: %v", err)
    49  	}
    50  
    51  	nodes := make([]resolve.NodeID, len(g.Nodes)-1)
    52  	for i := range nodes {
    53  		nodes[i] = resolve.NodeID(i + 1)
    54  	}
    55  
    56  	subgraphs := resolution.ComputeSubgraphs(g, nodes)
    57  	for _, sg := range subgraphs {
    58  		checkSubgraphVersions(t, sg, g)
    59  		checkSubgraphEdges(t, sg)
    60  		checkSubgraphNodesReachable(t, sg)
    61  		checkSubgraphDistances(t, sg)
    62  	}
    63  }
    64  
    65  func TestConstrainingSubgraph(t *testing.T) {
    66  	const vulnPkgName = "vuln"
    67  	g, err := schema.ParseResolve(`
    68  root 1.0.0
    69  	vuln: vuln@<3 1.0.1
    70  	nonprob1@^1.0.0 1.0.0
    71  		$vuln@>1
    72  	prob1@^1.0.0 1.0.0
    73  		$vuln@^1.0.0
    74  	prob2@^2.0.0 2.0.0
    75  		nonprob2@* 1.0.0
    76  			$vuln@*
    77  		$vuln@*
    78  		dep@3.0.0 3.0.0
    79  			$vuln@1.0.1
    80  `, resolve.NPM)
    81  	if err != nil {
    82  		t.Fatalf("failed to parse test graph: %v", err)
    83  	}
    84  
    85  	nID := slices.IndexFunc(g.Nodes, func(n resolve.Node) bool { return n.Version.Name == vulnPkgName })
    86  	if nID < 0 {
    87  		t.Fatalf("failed to find vulnerable node in test graph")
    88  	}
    89  	subgraph := resolution.ComputeSubgraphs(g, []resolve.NodeID{resolve.NodeID(nID)})[0]
    90  
    91  	cl := resolve.NewLocalClient()
    92  	v := resolve.Version{
    93  		VersionKey: resolve.VersionKey{
    94  			PackageKey: resolve.PackageKey{
    95  				System: resolve.NPM,
    96  				Name:   vulnPkgName,
    97  			},
    98  			VersionType: resolve.Concrete,
    99  		},
   100  	}
   101  	v.Version = "1.0.0"
   102  	cl.AddVersion(v, []resolve.RequirementVersion{})
   103  	v.Version = "1.0.1"
   104  	cl.AddVersion(v, []resolve.RequirementVersion{})
   105  	v.Version = "2.0.0"
   106  	cl.AddVersion(v, []resolve.RequirementVersion{})
   107  	vuln := &osvpb.Vulnerability{
   108  		Id: "VULN-001",
   109  		Affected: []*osvpb.Affected{{
   110  			Package: &osvpb.Package{
   111  				Ecosystem: "npm",
   112  				Name:      vulnPkgName,
   113  			},
   114  			Ranges: []*osvpb.Range{
   115  				{
   116  					Type:   osvpb.Range_SEMVER,
   117  					Events: []*osvpb.Event{{Introduced: "0"}, {Fixed: "2.0.0"}},
   118  				},
   119  			},
   120  		},
   121  		}}
   122  	got := subgraph.ConstrainingSubgraph(t.Context(), cl, vuln)
   123  	checkSubgraphVersions(t, got, g)
   124  	checkSubgraphEdges(t, got)
   125  	checkSubgraphNodesReachable(t, got)
   126  	checkSubgraphDistances(t, got)
   127  
   128  	// Checking that we have the expected remaining nodes
   129  	expectedRemoved := []string{"nonprob1", "nonprob2"}
   130  	for _, pkgName := range expectedRemoved {
   131  		nID := slices.IndexFunc(g.Nodes, func(n resolve.Node) bool { return n.Version.Name == pkgName })
   132  		if nID < 0 {
   133  			t.Fatalf("failed to find expected node in test graph")
   134  		}
   135  		if _, found := got.Nodes[resolve.NodeID(nID)]; found {
   136  			t.Errorf("non-constraining node was not removed from constraining subgraph: %s", pkgName)
   137  		}
   138  	}
   139  	if len(got.Nodes) != len(subgraph.Nodes)-len(expectedRemoved) {
   140  		t.Errorf("extraneous nodes found in constraining subgraph")
   141  	}
   142  	for nID := range got.Nodes {
   143  		if _, ok := subgraph.Nodes[nID]; !ok {
   144  			t.Errorf("extraneous node (%v) found in constraining subgraph", nID)
   145  		}
   146  	}
   147  
   148  	// Check that ConstrainingSubgraph is stable if reapplied
   149  	again := got.ConstrainingSubgraph(t.Context(), cl, vuln)
   150  	if diff := gocmp.Diff(got, again); diff != "" {
   151  		t.Errorf("ConstrainingSubgraph output changed on reapply (-want +got):\n%s", diff)
   152  	}
   153  }
   154  
   155  func TestSubgraphIsDevOnly(t *testing.T) {
   156  	g, err := schema.ParseResolve(`
   157  a 1.0.0
   158  	b@1.0.0 1.0.0
   159  		prod: prod@1.0.0 1.0.0
   160  	Dev|c@1.0.0 1.0.0
   161  		$prod@1.0.0
   162  		dev: dev@1.0.0 1.0.0
   163  	Dev|d@1.0.0 1.0.0
   164  		$dev@1.0.0
   165  `, resolve.NPM)
   166  	if err != nil {
   167  		t.Fatalf("failed to parse test graph: %v", err)
   168  	}
   169  
   170  	prodID := slices.IndexFunc(g.Nodes, func(n resolve.Node) bool { return n.Version.Name == "prod" })
   171  	if prodID < 0 {
   172  		t.Fatalf("failed to find vulnerable node in test graph")
   173  	}
   174  	devID := slices.IndexFunc(g.Nodes, func(n resolve.Node) bool { return n.Version.Name == "dev" })
   175  	if devID < 0 {
   176  		t.Fatalf("failed to find vulnerable node in test graph")
   177  	}
   178  
   179  	subgraphs := resolution.ComputeSubgraphs(g, []resolve.NodeID{resolve.NodeID(prodID), resolve.NodeID(devID)})
   180  	prodGraph := subgraphs[0]
   181  	devGraph := subgraphs[1]
   182  
   183  	if prodGraph.IsDevOnly(nil) {
   184  		t.Errorf("non-dev subgraph has IsDevOnly(nil) == true")
   185  	}
   186  	if !devGraph.IsDevOnly(nil) {
   187  		t.Errorf("dev-only subgraph has IsDevOnly(nil) == false")
   188  	}
   189  
   190  	groups := map[manifest.RequirementKey][]string{
   191  		npm.RequirementKey{PackageKey: resolve.PackageKey{System: resolve.NPM, Name: "c"}, KnownAs: ""}: {"dev"},
   192  		npm.RequirementKey{PackageKey: resolve.PackageKey{System: resolve.NPM, Name: "d"}, KnownAs: ""}: {"dev"},
   193  	}
   194  	if prodGraph.IsDevOnly(groups) {
   195  		t.Errorf("non-dev subgraph has IsDevOnly(groups) == true")
   196  	}
   197  	if !devGraph.IsDevOnly(groups) {
   198  		t.Errorf("dev-only subgraph has IsDevOnly(groups) == false")
   199  	}
   200  }
   201  
   202  func checkSubgraphVersions(t *testing.T, sg *resolution.DependencySubgraph, g *resolve.Graph) {
   203  	// Check that the nodes and versions in the subgraph are correct
   204  	t.Helper()
   205  	if _, ok := sg.Nodes[0]; !ok {
   206  		t.Errorf("DependencySubgraph missing root node (0)")
   207  	}
   208  	if _, ok := sg.Nodes[sg.Dependency]; !ok {
   209  		t.Errorf("DependencySubgraph missing Dependency node (%v)", sg.Dependency)
   210  	}
   211  	for nID, node := range sg.Nodes {
   212  		if nID < 0 || int(nID) >= len(g.Nodes) {
   213  			t.Errorf("DependencySubgraph contains invalid node ID: %v", nID)
   214  			continue
   215  		}
   216  		want := g.Nodes[nID].Version
   217  		got := node.Version
   218  		if diff := gocmp.Diff(want, got); diff != "" {
   219  			t.Errorf("DependencySubgraph node %v does not match Graph (-want +got):\n%s", nID, diff)
   220  		}
   221  	}
   222  }
   223  
   224  func checkSubgraphEdges(t *testing.T, sg *resolution.DependencySubgraph) {
   225  	// Check that every edge in a node's Parents appears in that parent's Children and vice versa.
   226  	t.Helper()
   227  	// Check the root node has no parents & end node has no children
   228  	if root, ok := sg.Nodes[0]; !ok {
   229  		t.Errorf("DependencySubgraph missing root node (0)")
   230  	} else if len(root.Parents) != 0 {
   231  		t.Errorf("DependencySubgraph root node (0) has parent nodes: %v", root.Parents)
   232  	}
   233  	if end, ok := sg.Nodes[sg.Dependency]; !ok {
   234  		t.Errorf("DependencySubgraph missing Dependency node (%v)", sg.Dependency)
   235  	} else if len(end.Children) != 0 {
   236  		t.Errorf("DependencySubgraph Dependency node (%v) has child nodes: %v", sg.Dependency, end.Children)
   237  	}
   238  
   239  	edgeEq := func(a, b resolve.Edge) bool {
   240  		return a.From == b.From &&
   241  			a.To == b.To &&
   242  			a.Requirement == b.Requirement &&
   243  			a.Type.Compare(b.Type) == 0
   244  	}
   245  
   246  	// Check each node's parents/children for same edges
   247  	for nID, node := range sg.Nodes {
   248  		// Only the root node should have no parents
   249  		if len(node.Parents) == 0 && nID != 0 {
   250  			t.Errorf("DependencySubgraph node %v has no parent nodes", nID)
   251  		}
   252  		for _, e := range node.Parents {
   253  			if e.To != nID {
   254  				t.Errorf("DependencySubgraph node %v contains invalid parent edge: %v", nID, e)
   255  				continue
   256  			}
   257  			parent, ok := sg.Nodes[e.From]
   258  			if !ok {
   259  				t.Errorf("DependencySubgraph edge missing node in subgraph: %v", e)
   260  			}
   261  			if !slices.ContainsFunc(parent.Children, func(edge resolve.Edge) bool { return edgeEq(e, edge) }) {
   262  				t.Errorf("DependencySubgraph node %v missing child edge: %v", e.From, e)
   263  			}
   264  		}
   265  
   266  		// Only the end node should have no children
   267  		if len(node.Children) == 0 && nID != sg.Dependency {
   268  			t.Errorf("DependencySubgraph node %v has no child nodes", nID)
   269  		}
   270  		for _, e := range node.Children {
   271  			if e.From != nID {
   272  				t.Errorf("DependencySubgraph node %v contains invalid child edge: %v", nID, e)
   273  				continue
   274  			}
   275  			child, ok := sg.Nodes[e.To]
   276  			if !ok {
   277  				t.Errorf("DependencySubgraph edge missing node in subgraph: %v", e)
   278  			}
   279  			if !slices.ContainsFunc(child.Parents, func(edge resolve.Edge) bool { return edgeEq(e, edge) }) {
   280  				t.Errorf("DependencySubgraph node %v missing parent edge: %v", e.To, e)
   281  			}
   282  		}
   283  	}
   284  }
   285  
   286  func checkSubgraphNodesReachable(t *testing.T, sg *resolution.DependencySubgraph) {
   287  	// Check that every node in the subgraph is reachable from the root node.
   288  	t.Helper()
   289  	seen := make(map[resolve.NodeID]struct{})
   290  	todo := make([]resolve.NodeID, 0, len(sg.Nodes))
   291  	todo = append(todo, 0)
   292  	seen[0] = struct{}{}
   293  	for len(todo) > 0 {
   294  		nID := todo[0]
   295  		todo = todo[1:]
   296  		node, ok := sg.Nodes[nID]
   297  		if !ok {
   298  			t.Errorf("DependencySubgraph missing expected node %v", nID)
   299  			continue
   300  		}
   301  		for _, e := range node.Children {
   302  			if _, ok := seen[e.To]; !ok {
   303  				todo = append(todo, e.To)
   304  				seen[e.To] = struct{}{}
   305  			}
   306  		}
   307  	}
   308  
   309  	got := slices.Sorted(maps.Keys(seen))
   310  	want := slices.Sorted(maps.Keys(sg.Nodes))
   311  	if diff := gocmp.Diff(want, got); diff != "" {
   312  		t.Errorf("DependencySubgraph reachable nodes mismatch (-want +got):\n%s", diff)
   313  	}
   314  }
   315  
   316  func checkSubgraphDistances(t *testing.T, sg *resolution.DependencySubgraph) {
   317  	// Check that the distances of each node have the correct value.
   318  	t.Helper()
   319  	if end, ok := sg.Nodes[sg.Dependency]; !ok {
   320  		t.Errorf("DependencySubgraph missing Dependency node (%v)", sg.Dependency)
   321  	} else if end.Distance != 0 {
   322  		t.Errorf("DependencySubgraph end Dependency distance is not 0")
   323  	}
   324  
   325  	// Each node's distance should be one more than its smallest child's distance.
   326  	for nID, node := range sg.Nodes {
   327  		// The end dependency should have a distance of 0
   328  		if nID == sg.Dependency {
   329  			if node.Distance != 0 {
   330  				t.Errorf("DependencySubgraph Dependency node (%v) has nonzero distance: %d", nID, node.Distance)
   331  			}
   332  
   333  			continue
   334  		}
   335  
   336  		if len(node.Children) == 0 {
   337  			t.Errorf("DependencySubgraph node %v has no child nodes", nID)
   338  			continue
   339  		}
   340  		e := slices.MinFunc(node.Children, func(a, b resolve.Edge) int { return cmp.Compare(sg.Nodes[a.To].Distance, sg.Nodes[b.To].Distance) })
   341  		want := sg.Nodes[e.To].Distance + 1
   342  		if node.Distance != want {
   343  			t.Errorf("DependencySubgraph node %v Distance = %d, want = %d", nID, node.Distance, want)
   344  		}
   345  	}
   346  }