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  }