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 }