github.com/google/osv-scalibr@v0.4.1/guidedremediation/internal/tui/components/dependency_graph.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 components 16 17 import ( 18 "fmt" 19 "slices" 20 "strings" 21 22 "deps.dev/util/resolve" 23 "github.com/charmbracelet/lipgloss" 24 "github.com/google/osv-scalibr/guidedremediation/internal/resolution" 25 ) 26 27 type chainGraphNode struct { 28 vk resolve.VersionKey 29 isDirect bool // if this is a direct dependency 30 dependents []*chainGraphNode 31 // in this representation, the dependents are the children of this node 32 // so the root of the tree is rendered at the bottom 33 } 34 35 // ChainGraph is a renderable graph of the shortest paths from each direct dependency to a single vulnerable node. 36 type ChainGraph struct { 37 *chainGraphNode 38 } 39 40 func subgraphEdges(sg *resolution.DependencySubgraph, direct resolve.NodeID) []resolve.Edge { 41 // find the shortest chain of edges from direct to the vulnerable node, excluding the root->direct edge. 42 // return them in reverse order, with edges[0].To = sg.Dependency 43 edges := make([]resolve.Edge, 0, sg.Nodes[0].Distance-1) 44 nID := direct 45 for nID != sg.Dependency { 46 n := sg.Nodes[nID] 47 idx := slices.IndexFunc(n.Children, func(e resolve.Edge) bool { return sg.Nodes[e.To].Distance == n.Distance-1 }) 48 if idx < 0 { 49 break 50 } 51 edge := n.Children[idx] 52 edges = append(edges, edge) 53 nID = edge.To 54 } 55 slices.Reverse(edges) 56 57 return edges 58 } 59 60 // FindChainGraphs constructs a graph of the shortest paths from each direct dependency to each unique vulnerable node 61 func FindChainGraphs(subgraphs []*resolution.DependencySubgraph) []ChainGraph { 62 // Construct the ChainGraphs 63 ret := make([]ChainGraph, 0, len(subgraphs)) 64 for _, sg := range subgraphs { 65 nodes := make(map[resolve.NodeID]*chainGraphNode) 66 isDirect := func(nID resolve.NodeID) bool { 67 return slices.ContainsFunc(sg.Nodes[nID].Parents, func(e resolve.Edge) bool { return e.From == 0 }) 68 } 69 // Create and add the vulnerable node to the returned graphs 70 n := &chainGraphNode{ 71 vk: sg.Nodes[sg.Dependency].Version, 72 dependents: nil, 73 isDirect: isDirect(sg.Dependency), 74 } 75 ret = append(ret, ChainGraph{n}) 76 nodes[sg.Dependency] = n 77 for _, startEdge := range sg.Nodes[0].Children { 78 // Going up the chain, add the node to the previous' children if it's not there already 79 for _, e := range subgraphEdges(sg, startEdge.To) { 80 p := nodes[e.To] 81 n, ok := nodes[e.From] 82 if !ok { 83 n = &chainGraphNode{ 84 vk: sg.Nodes[e.From].Version, 85 dependents: nil, 86 isDirect: isDirect(e.From), 87 } 88 nodes[e.From] = n 89 } 90 if !slices.Contains(p.dependents, n) { 91 p.dependents = append(p.dependents, n) 92 } 93 } 94 } 95 } 96 97 return ret 98 } 99 100 func (c ChainGraph) String() string { 101 if c.chainGraphNode == nil { 102 return "" 103 } 104 s, _ := c.subString(true) 105 // Fill in the missing whitespace 106 w := lipgloss.Width(s) 107 h := lipgloss.Height(s) 108 // need to use w+1 to force lipgloss to place whitespace 109 return lipgloss.Place(w+1, h, lipgloss.Left, lipgloss.Top, s) 110 } 111 112 var ( 113 directNodeStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")).Margin(0, 1) // blue text 114 vulnNodeStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15")).Background(lipgloss.Color("1")).Padding(0, 1) // white on red background 115 directVulnNodeStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15")).Background(lipgloss.Color("5")).Padding(0, 1) // white on purple background 116 ) 117 118 // recursive construction of the visualized tree 119 // returns the subtree and the offset for where a child should connect to this 120 func (c *chainGraphNode) subString(isVuln bool) (string, int) { 121 nodeStr := fmt.Sprintf("%s@%s", c.vk.Name, c.vk.Version) 122 switch { 123 case isVuln && c.isDirect: 124 nodeStr = directVulnNodeStyle.Render(nodeStr) 125 case isVuln: 126 nodeStr = vulnNodeStyle.Render(nodeStr) 127 case c.isDirect: 128 nodeStr = directNodeStyle.Render(nodeStr) 129 } 130 nodeOffset := lipgloss.Width(nodeStr) / 2 131 132 // No children, just show the text 133 if len(c.dependents) == 0 { 134 return nodeStr, nodeOffset 135 } 136 137 // one child, add a single line connecting this to the child above it 138 if len(c.dependents) == 1 { 139 childStr, childCenter := c.dependents[0].subString(false) 140 if nodeOffset > childCenter { 141 // left-pad the child if the parent is wider 142 childStr = lipgloss.JoinHorizontal(lipgloss.Bottom, strings.Repeat(" ", nodeOffset-childCenter), childStr) 143 childCenter = nodeOffset 144 } 145 nodeStr = strings.Repeat(" ", childCenter-nodeOffset) + nodeStr 146 joinerStr := strings.Repeat(" ", childCenter) + "│" 147 148 return fmt.Sprintf("%s\n%s\n%s", childStr, joinerStr, nodeStr), childCenter 149 } 150 151 // multiple children: 152 // Join the children together on one line 153 nChilds := len(c.dependents) 154 paddedChildStrings := make([]string, 0, 2*nChilds) // string of children, with padding strings in between 155 childOffsets := make([]int, 0, nChilds) // where above the children to connect the lines to them 156 width := 0 157 for _, ch := range c.dependents { 158 str, off := ch.subString(false) 159 paddedChildStrings = append(paddedChildStrings, str, " ") 160 childOffsets = append(childOffsets, width+off) 161 width += lipgloss.Width(str) + 1 162 } 163 joinedChildren := lipgloss.JoinHorizontal(lipgloss.Bottom, paddedChildStrings...) 164 165 // create the connecting line 166 // connector bits: ┌ ─ ┼ ┐ ┬ ┴ ┘ └ 167 firstOffset := childOffsets[0] 168 lastOffset := childOffsets[nChilds-1] 169 var midOffset int // where on the line to connect the parent 170 if nChilds%2 == 0 { 171 // if there's an even number of children, connect between the middle two 172 midOffset = (childOffsets[nChilds/2-1] + childOffsets[nChilds/2]) / 2 173 } else { 174 // otherwise, connect inline with the middle child 175 midOffset = childOffsets[nChilds/2] 176 } 177 178 line := make([]rune, lastOffset+1) 179 offsetIdx := 0 180 for i := range line { 181 switch { 182 case i < firstOffset: 183 line[i] = ' ' 184 case i == firstOffset: 185 line[i] = '└' 186 offsetIdx++ 187 case i == lastOffset: 188 line[i] = '┘' 189 offsetIdx++ 190 case i == midOffset: 191 if i == childOffsets[offsetIdx] { 192 line[i] = '┼' 193 offsetIdx++ 194 } else { 195 line[i] = '┬' 196 } 197 case i == childOffsets[offsetIdx]: 198 line[i] = '┴' 199 offsetIdx++ 200 default: 201 line[i] = '─' 202 } 203 } 204 205 // join everything together 206 linedChildren := fmt.Sprintf("%s\n%s", joinedChildren, string(line)) 207 if nodeOffset > midOffset { 208 // left-pad the children if the parent is wider 209 linedChildren = lipgloss.JoinHorizontal(lipgloss.Bottom, strings.Repeat(" ", nodeOffset-midOffset), linedChildren) 210 midOffset = nodeOffset 211 } 212 213 nodeStr = strings.Repeat(" ", midOffset-nodeOffset) + nodeStr 214 215 return fmt.Sprintf("%s\n%s", linedChildren, nodeStr), midOffset 216 }