github.com/google/osv-scalibr@v0.4.1/guidedremediation/internal/tui/model/state_in_place_result.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 model
    16  
    17  import (
    18  	"fmt"
    19  	"slices"
    20  	"strings"
    21  
    22  	"deps.dev/util/resolve"
    23  	"github.com/charmbracelet/bubbles/key"
    24  	tea "github.com/charmbracelet/bubbletea"
    25  	"github.com/google/osv-scalibr/guidedremediation/internal/parser"
    26  	"github.com/google/osv-scalibr/guidedremediation/internal/remediation"
    27  	"github.com/google/osv-scalibr/guidedremediation/internal/resolution"
    28  	"github.com/google/osv-scalibr/guidedremediation/internal/tui/components"
    29  	"github.com/google/osv-scalibr/guidedremediation/result"
    30  )
    31  
    32  type stateInPlaceResult struct {
    33  	cursorPos inPlaceCursorPos
    34  	canRelock bool
    35  
    36  	selectedChanges []bool
    37  
    38  	vulnList       components.ViewModel
    39  	inPlaceInfo    components.ViewModel
    40  	relockFixVulns components.ViewModel
    41  
    42  	focusedInfo components.ViewModel
    43  }
    44  
    45  type inPlaceCursorPos int
    46  
    47  const (
    48  	inPlaceFixed inPlaceCursorPos = iota
    49  	inPlaceRemain
    50  	inPlaceChoice
    51  	inPlaceWrite
    52  	inPlaceRelock
    53  	inPlaceQuit
    54  	inPlaceEnd
    55  )
    56  
    57  func newStateInPlaceResult(m Model, inPlaceInfo components.ViewModel, selectedChanges []bool) stateInPlaceResult {
    58  	s := stateInPlaceResult{
    59  		cursorPos:   inPlaceChoice,
    60  		inPlaceInfo: inPlaceInfo,
    61  	}
    62  
    63  	// If created without a selection, choose all compatible patches.
    64  	if selectedChanges == nil {
    65  		selectedChanges = chooseAllCompatiblePatches(m.lockfilePatches)
    66  	}
    67  	s.selectedChanges = selectedChanges
    68  
    69  	// pre-generate the info views for each option
    70  	// Get the list of remaining vulns
    71  	vulns := inPlaceUnfixable(m)
    72  	s.vulnList = components.NewVulnList(vulns, "", m.detailsRenderer)
    73  
    74  	// recompute the vulns fixed by relocking after the in-place update
    75  	if m.options.Manifest != "" {
    76  		s.canRelock = true
    77  		var relockFixes []resolution.Vulnerability
    78  		for _, v := range vulns {
    79  			if !slices.ContainsFunc(m.relockBaseManifest.Vulns, func(r resolution.Vulnerability) bool {
    80  				return r.OSV.Id == v.OSV.Id
    81  			}) {
    82  				relockFixes = append(relockFixes, v)
    83  			}
    84  		}
    85  		s.relockFixVulns = components.NewVulnList(relockFixes, "Relocking fixes the following vulns:", m.detailsRenderer)
    86  	} else {
    87  		s.canRelock = false
    88  		s.relockFixVulns = components.TextView("Re-run with manifest to resolve vulnerabilities by re-locking")
    89  	}
    90  
    91  	s = s.ResizeInfo(m.viewWidth, m.viewHeight).(stateInPlaceResult)
    92  	return s
    93  }
    94  
    95  func (st stateInPlaceResult) Init(m Model) tea.Cmd {
    96  	return nil
    97  }
    98  
    99  func (st stateInPlaceResult) Update(m Model, msg tea.Msg) (tea.Model, tea.Cmd) {
   100  	var cmd tea.Cmd
   101  	switch msg := msg.(type) {
   102  	case writeMsg: // just finished writing & installing the lockfile
   103  		if msg.err != nil {
   104  			return errorAndExit(m, msg.err)
   105  		}
   106  		// re-parse the lockfile
   107  		cmd = doInPlaceResolutionCmd(m.options, m.lockfileRW)
   108  	case inPlaceResolutionMsg:
   109  		if msg.err != nil {
   110  			return errorAndExit(m, msg.err)
   111  		}
   112  		m.writing = false
   113  		m.lockfilePatches = msg.allPatches
   114  		m.lockfileGraph = msg.resolvedGraph
   115  		st.selectedChanges = make([]bool, len(m.lockfilePatches)) // unselect all patches
   116  		st.inPlaceInfo = components.NewInPlaceInfo(m.lockfilePatches, m.lockfileGraph.Vulns, m.detailsRenderer)
   117  	case components.ViewModelCloseMsg:
   118  		// info view wants to quit, just unfocus it
   119  		st.focusedInfo = nil
   120  	case tea.KeyMsg:
   121  		switch {
   122  		case key.Matches(msg, components.Keys.SwitchView):
   123  			if st.IsInfoFocused() {
   124  				st.focusedInfo = nil
   125  			} else if view, canFocus := st.currentInfoView(); canFocus {
   126  				st.focusedInfo = view
   127  			}
   128  		case st.IsInfoFocused():
   129  			st.focusedInfo, cmd = st.focusedInfo.Update(msg)
   130  		case key.Matches(msg, components.Keys.Quit):
   131  			// only quit if the cursor is over the quit line
   132  			if st.cursorPos == inPlaceQuit {
   133  				return m, tea.Quit
   134  			}
   135  			// move the cursor to the quit line if it's not already there
   136  			st.cursorPos = inPlaceQuit
   137  		case key.Matches(msg, components.Keys.Select):
   138  			// enter key was pressed, parse input
   139  			return st.parseInput(m)
   140  		// move the cursor and show the corresponding info view
   141  		case key.Matches(msg, components.Keys.Up):
   142  			if st.cursorPos > inPlaceFixed {
   143  				st.cursorPos--
   144  			}
   145  		case key.Matches(msg, components.Keys.Down):
   146  			if st.cursorPos < inPlaceEnd-1 {
   147  				st.cursorPos++
   148  			}
   149  		}
   150  	}
   151  
   152  	m.st = st
   153  	return m, cmd
   154  }
   155  
   156  func (st stateInPlaceResult) currentInfoView() (view components.ViewModel, canFocus bool) {
   157  	switch st.cursorPos {
   158  	case inPlaceFixed: // info - fixed vulns
   159  		return st.inPlaceInfo, true
   160  	case inPlaceRemain: // info - remaining vulns
   161  		return st.vulnList, true
   162  	case inPlaceChoice: // choose changes
   163  		return components.TextView("Choose which changes to apply"), false
   164  	case inPlaceWrite: // write
   165  		return components.TextView("Write changes to lockfile"), false
   166  	case inPlaceRelock: // relock
   167  		return st.relockFixVulns, st.canRelock
   168  	case inPlaceQuit: // quit
   169  		return components.TextView("Exit Guided Remediation"), false
   170  	case inPlaceEnd:
   171  		fallthrough
   172  	default:
   173  		return components.TextView(""), false
   174  	}
   175  }
   176  
   177  func (st stateInPlaceResult) parseInput(m Model) (tea.Model, tea.Cmd) {
   178  	var cmd tea.Cmd
   179  	switch st.cursorPos {
   180  	case inPlaceFixed, inPlaceRemain: // info lines, focus info view
   181  		v, _ := st.currentInfoView()
   182  		st.focusedInfo = v
   183  	case inPlaceChoice: // choose specific patches
   184  		m.st = newStateChooseInPlacePatches(m, st)
   185  		cmd = m.st.Init(m)
   186  		return m, cmd
   187  	case inPlaceWrite: // write
   188  		m.writing = true
   189  		cmd = func() tea.Msg { return st.write(m) }
   190  	case inPlaceRelock: // relock
   191  		if st.canRelock {
   192  			m.st = newStateRelockResult(m)
   193  			cmd = m.st.Init(m)
   194  			return m, cmd
   195  		}
   196  	case inPlaceQuit: // quit
   197  		cmd = tea.Quit
   198  	case inPlaceEnd:
   199  	}
   200  	m.st = st
   201  
   202  	return m, cmd
   203  }
   204  
   205  func (st stateInPlaceResult) View(m Model) string {
   206  	if m.writing {
   207  		return ""
   208  	}
   209  	remainCount := len(inPlaceUnfixable(m))
   210  	fixCount := countVulns(m.lockfileGraph.Vulns, m.options.RemediationOptions).total - remainCount
   211  	pkgCount := len(m.lockfilePatches)
   212  	nSelected := 0
   213  	for _, s := range st.selectedChanges {
   214  		if s {
   215  			nSelected++
   216  		}
   217  	}
   218  
   219  	s := strings.Builder{}
   220  	s.WriteString("IN-PLACE\n")
   221  	s.WriteString(components.RenderSelectorOption(
   222  		st.cursorPos == inPlaceFixed,
   223  		"",
   224  		fmt.Sprintf("%%s can be changed, fixing %d vulnerabilities\n", fixCount),
   225  		fmt.Sprintf("%d packages", pkgCount),
   226  	))
   227  	s.WriteString(components.RenderSelectorOption(
   228  		st.cursorPos == inPlaceRemain,
   229  		"",
   230  		"%s remain\n",
   231  		fmt.Sprintf("%d vulnerabilities", remainCount),
   232  	))
   233  
   234  	s.WriteString("\n")
   235  
   236  	s.WriteString("Actions:\n")
   237  	s.WriteString(components.RenderSelectorOption(
   238  		st.cursorPos == inPlaceChoice,
   239  		" > ",
   240  		"%s which changes to apply\n",
   241  		"Choose",
   242  	))
   243  	s.WriteString(components.RenderSelectorOption(
   244  		st.cursorPos == inPlaceWrite,
   245  		" > ",
   246  		fmt.Sprintf("%%s %d changes to lockfile\n", nSelected),
   247  		"Write",
   248  	))
   249  	if st.canRelock {
   250  		s.WriteString(components.RenderSelectorOption(
   251  			st.cursorPos == inPlaceRelock,
   252  			" > ",
   253  			"%s the whole project instead\n",
   254  			"Relock",
   255  		))
   256  	} else {
   257  		s.WriteString(components.RenderSelectorOption(
   258  			st.cursorPos == inPlaceRelock,
   259  			" > ",
   260  			components.DisabledTextStyle.Render("Cannot re-lock - missing manifest file\n"),
   261  		))
   262  	}
   263  	s.WriteString("\n")
   264  	s.WriteString(components.RenderSelectorOption(
   265  		st.cursorPos == inPlaceQuit,
   266  		"> ",
   267  		"%s without saving changes\n",
   268  		"quit",
   269  	))
   270  
   271  	return s.String()
   272  }
   273  
   274  func (st stateInPlaceResult) InfoView() string {
   275  	v, _ := st.currentInfoView()
   276  	return v.View()
   277  }
   278  
   279  func (st stateInPlaceResult) Resize(_, _ int) modelState { return st }
   280  
   281  func (st stateInPlaceResult) ResizeInfo(w, h int) modelState {
   282  	st.inPlaceInfo = st.inPlaceInfo.Resize(w, h)
   283  	st.vulnList = st.vulnList.Resize(w, h)
   284  	st.relockFixVulns = st.relockFixVulns.Resize(w, h)
   285  	return st
   286  }
   287  
   288  func (st stateInPlaceResult) IsInfoFocused() bool {
   289  	return st.focusedInfo != nil
   290  }
   291  
   292  func (st stateInPlaceResult) write(m Model) tea.Msg {
   293  	var patches []result.Patch
   294  	for i, p := range m.lockfilePatches {
   295  		if st.selectedChanges[i] {
   296  			patches = append(patches, p)
   297  		}
   298  	}
   299  
   300  	return writeMsg{parser.WriteLockfilePatches(m.options.Lockfile, patches, m.lockfileRW)}
   301  }
   302  
   303  func chooseAllCompatiblePatches(allPatches []result.Patch) []bool {
   304  	choices := make([]bool, len(allPatches))
   305  	pkgChanges := make(map[result.Package]struct{}) // dependencies we've already applied a patch to
   306  	type vulnIdentifier struct {
   307  		id         string
   308  		pkgName    string
   309  		pkgVersion string
   310  	}
   311  	fixedVulns := make(map[vulnIdentifier]struct{}) // vulns that have already been fixed by a patch
   312  	for i, patch := range allPatches {
   313  		// If this patch is incompatible with existing patches, skip adding it to the patch list.
   314  
   315  		// A patch is incompatible if any of its changed packages have already been changed by an existing patch.
   316  		if slices.ContainsFunc(patch.PackageUpdates, func(p result.PackageUpdate) bool {
   317  			_, ok := pkgChanges[result.Package{Name: p.Name, Version: p.VersionFrom}]
   318  			return ok
   319  		}) {
   320  			continue
   321  		}
   322  		// A patch is also incompatible if any fixed vulnerability has already been fixed by another patch.
   323  		// This would happen if updating the version of one package has a side effect of also updating or removing one of its vulnerable dependencies.
   324  		// e.g. We have {foo@1 -> bar@1}, and two possible patches [foo@3, bar@2].
   325  		// Patching foo@3 makes {foo@3 -> bar@3}, which also fixes the vulnerability in bar.
   326  		// Applying both patches would force {foo@3 -> bar@2}, which is less desirable.
   327  		if slices.ContainsFunc(patch.Fixed, func(v result.Vuln) bool {
   328  			identifier := vulnIdentifier{
   329  				id:         v.ID,
   330  				pkgName:    patch.PackageUpdates[0].Name,
   331  				pkgVersion: patch.PackageUpdates[0].VersionFrom,
   332  			}
   333  			_, ok := fixedVulns[identifier]
   334  			return ok
   335  		}) {
   336  			continue
   337  		}
   338  
   339  		choices[i] = true
   340  		for _, pkg := range patch.PackageUpdates {
   341  			pkgChanges[result.Package{Name: pkg.Name, Version: pkg.VersionFrom}] = struct{}{}
   342  		}
   343  		for _, v := range patch.Fixed {
   344  			identifier := vulnIdentifier{
   345  				id:         v.ID,
   346  				pkgName:    patch.PackageUpdates[0].Name,
   347  				pkgVersion: patch.PackageUpdates[0].VersionFrom,
   348  			}
   349  			fixedVulns[identifier] = struct{}{}
   350  		}
   351  	}
   352  	return choices
   353  }
   354  
   355  func inPlaceUnfixable(m Model) []resolution.Vulnerability {
   356  	var vulns []resolution.Vulnerability
   357  	for _, vuln := range m.lockfileGraph.Vulns {
   358  		seenPkgsVulnIdx := make(map[resolve.VersionKey]int)
   359  		for _, sg := range vuln.Subgraphs {
   360  			v := resolution.Vulnerability{
   361  				OSV:       vuln.OSV,
   362  				Subgraphs: []*resolution.DependencySubgraph{sg},
   363  				DevOnly:   sg.IsDevOnly(nil),
   364  			}
   365  			if !remediation.MatchVuln(m.options.RemediationOptions, v) {
   366  				continue
   367  			}
   368  			node := sg.Nodes[sg.Dependency]
   369  			if idx, ok := seenPkgsVulnIdx[node.Version]; ok {
   370  				vulns[idx].Subgraphs = append(vulns[idx].Subgraphs, sg)
   371  				vulns[idx].DevOnly = vulns[idx].DevOnly && v.DevOnly
   372  				continue
   373  			}
   374  			if !slices.ContainsFunc(m.lockfilePatches, func(p result.Patch) bool {
   375  				fixesVulnID := slices.ContainsFunc(p.Fixed, func(rv result.Vuln) bool {
   376  					return rv.ID == v.OSV.Id
   377  				})
   378  				changesPackage := slices.ContainsFunc(p.PackageUpdates, func(p result.PackageUpdate) bool {
   379  					return p.Name == node.Version.Name && p.VersionFrom == node.Version.Version
   380  				})
   381  				return fixesVulnID && changesPackage
   382  			}) {
   383  				vulns = append(vulns, v)
   384  				seenPkgsVulnIdx[node.Version] = len(vulns) - 1
   385  			}
   386  		}
   387  	}
   388  	return vulns
   389  }