github.com/google/osv-scalibr@v0.4.1/guidedremediation/internal/tui/model/state_relock_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  	"context"
    19  	"fmt"
    20  	"os"
    21  	"os/exec"
    22  	"path/filepath"
    23  	"slices"
    24  	"strings"
    25  
    26  	"deps.dev/util/resolve"
    27  	"github.com/charmbracelet/bubbles/key"
    28  	"github.com/charmbracelet/bubbles/spinner"
    29  	tea "github.com/charmbracelet/bubbletea"
    30  	"github.com/charmbracelet/lipgloss"
    31  	"github.com/google/osv-scalibr/guidedremediation/internal/manifest"
    32  	"github.com/google/osv-scalibr/guidedremediation/internal/parser"
    33  	"github.com/google/osv-scalibr/guidedremediation/internal/remediation"
    34  	"github.com/google/osv-scalibr/guidedremediation/internal/resolution"
    35  	"github.com/google/osv-scalibr/guidedremediation/internal/strategy/common"
    36  	"github.com/google/osv-scalibr/guidedremediation/internal/strategy/relax"
    37  	"github.com/google/osv-scalibr/guidedremediation/internal/tui/components"
    38  	"github.com/google/osv-scalibr/guidedremediation/options"
    39  	"github.com/google/osv-scalibr/guidedremediation/result"
    40  )
    41  
    42  type stateRelockResult struct {
    43  	currRes      *remediation.ResolvedManifest // In-progress relock result, with user-selected patches applied
    44  	currErrs     []result.ResolveError         // In-progress relock errors
    45  	patches      common.PatchResult            // current possible patches applicable to currRes
    46  	patchesDone  bool                          // whether the patches has finished being computed
    47  	numUnfixable int                           // count of unfixable vulns, for rendering
    48  
    49  	spinner         spinner.Model
    50  	cursorPos       int
    51  	selectedPatches map[int]struct{} // currently pending selected patches
    52  
    53  	vulnList      components.ViewModel
    54  	unfixableList components.ViewModel
    55  	patchInfo     []components.ViewModel
    56  	resolveErrors components.ViewModel
    57  
    58  	focusedInfo components.ViewModel // the ViewModel that is currently focused, nil if not focused
    59  }
    60  
    61  type relockCursorPos int
    62  
    63  const (
    64  	relockRemaining relockCursorPos = iota
    65  	relockUnfixable
    66  	relockErrors
    67  	relockPatches
    68  	relockApply
    69  	relockWrite
    70  	relockQuit
    71  	relockEnd
    72  )
    73  
    74  func newStateRelockResult(m Model) stateRelockResult {
    75  	st := stateRelockResult{
    76  		currRes:         m.relockBaseManifest,
    77  		currErrs:        m.relockBaseErrors,
    78  		resolveErrors:   makeErrorsView(m.relockBaseErrors),
    79  		patchesDone:     false,
    80  		spinner:         components.NewSpinner(),
    81  		cursorPos:       -1,
    82  		selectedPatches: make(map[int]struct{}),
    83  		vulnList:        components.NewVulnList(m.relockBaseManifest.Vulns, "", m.detailsRenderer),
    84  	}
    85  	st = st.ResizeInfo(m.viewWidth, m.viewHeight).(stateRelockResult)
    86  	return st
    87  }
    88  
    89  // getEffectiveCursor gets the cursor position, accounting for the arbitrary number of patches
    90  // returns relockPatches if over ANY of the patches
    91  func (st stateRelockResult) getEffectiveCursor() relockCursorPos {
    92  	if st.cursorPos < int(relockPatches) {
    93  		return relockCursorPos(st.cursorPos)
    94  	}
    95  
    96  	if len(st.patches.Patches) == 0 {
    97  		// skip over stateRelockPatches and stateRelockApply
    98  		return relockCursorPos(st.cursorPos + 2)
    99  	}
   100  
   101  	if st.cursorPos < int(relockPatches)+len(st.patches.Patches) {
   102  		return relockPatches
   103  	}
   104  
   105  	return relockCursorPos(st.cursorPos - len(st.patches.Patches) + 1)
   106  }
   107  
   108  // getEffectiveCursorFor gets the true cursor for the effective position,
   109  // accounting for the arbitrary number of patches.
   110  // getting relockPatches will get the position of the first patch.
   111  func (st stateRelockResult) getEffectiveCursorFor(pos relockCursorPos) int {
   112  	var offset int
   113  	switch {
   114  	case pos <= relockPatches:
   115  		offset = 0
   116  	case len(st.patches.Patches) == 0:
   117  		offset = -2
   118  	default:
   119  		offset = len(st.patches.Patches) - 1
   120  	}
   121  	return int(pos) + offset
   122  }
   123  
   124  // getPatchIndex gets the index of the patch the cursor is currently over
   125  func (st stateRelockResult) getPatchIndex() int {
   126  	return st.cursorPos - int(relockPatches)
   127  }
   128  
   129  func (st stateRelockResult) Init(m Model) tea.Cmd {
   130  	return tea.Batch(
   131  		st.spinner.Tick,
   132  		doComputeRelaxPatchesCmd(m.options, m.relockBaseManifest),
   133  	)
   134  }
   135  
   136  func (st stateRelockResult) Update(m Model, msg tea.Msg) (tea.Model, tea.Cmd) {
   137  	var cmd tea.Cmd
   138  	switch msg := msg.(type) {
   139  	case doRelockMsg: // finished resolving (after selecting multiple patches)
   140  		if msg.err != nil {
   141  			return errorAndExit(m, msg.err)
   142  		}
   143  		st.currRes = msg.resolvedManifest
   144  		// recreate the vuln list info view
   145  		st.vulnList = components.NewVulnList(st.currRes.Vulns, "", m.detailsRenderer)
   146  		st.currErrs = computeResolveErrors(st.currRes.Graph)
   147  		st.resolveErrors = makeErrorsView(st.currErrs)
   148  		// Compute possible patches again
   149  		st.patchesDone = false
   150  		cmd = doComputeRelaxPatchesCmd(m.options, st.currRes)
   151  	case relaxPatchMsg: // patch computation done
   152  		if msg.err != nil {
   153  			return errorAndExit(m, msg.err)
   154  		}
   155  		st.patches = msg.patches
   156  		clear(st.selectedPatches)
   157  		st = st.buildPatchInfoViews(m)
   158  		st.patchesDone = true
   159  		if len(st.patches.Patches) > 0 {
   160  			// place the cursor on the first patch
   161  			st.cursorPos = st.getEffectiveCursorFor(relockPatches)
   162  		} else {
   163  			// no patches, place the cursor on the 'write' line
   164  			st.cursorPos = st.getEffectiveCursorFor(relockWrite)
   165  		}
   166  
   167  	case writeMsg: // just finished writing & installing the manifest
   168  		if msg.err != nil {
   169  			return errorAndExit(m, msg.err)
   170  		}
   171  		m.writing = false
   172  		m.relockBaseManifest = st.currRes // relockBaseRes must match what is in the package.json
   173  		m.relockBaseErrors = st.currErrs
   174  		clear(st.selectedPatches)
   175  
   176  	case components.ViewModelCloseMsg:
   177  		// info view wants to quit, just unfocus it
   178  		st.focusedInfo = nil
   179  	case tea.KeyMsg:
   180  		if !st.patchesDone { // Don't accept input in the middle of computation
   181  			return m, nil
   182  		}
   183  		switch {
   184  		case key.Matches(msg, components.Keys.SwitchView):
   185  			if st.IsInfoFocused() {
   186  				st.focusedInfo = nil
   187  			} else if view, canFocus := st.currentInfoView(); canFocus {
   188  				st.focusedInfo = view
   189  			}
   190  		case st.IsInfoFocused():
   191  			st.focusedInfo, cmd = st.focusedInfo.Update(msg)
   192  		case key.Matches(msg, components.Keys.Quit):
   193  			// only quit if the cursor is over the quit line
   194  			if st.getEffectiveCursor() == relockQuit {
   195  				return m, tea.Quit
   196  			}
   197  			// move the cursor to the quit line if it's not already there
   198  			st.cursorPos = st.getEffectiveCursorFor(relockQuit)
   199  		case key.Matches(msg, components.Keys.Select): // enter key pressed
   200  			return st.parseInput(m)
   201  		// move the cursor
   202  		case key.Matches(msg, components.Keys.Up):
   203  			if st.getEffectiveCursor() > relockRemaining {
   204  				st.cursorPos--
   205  				if st.getEffectiveCursor() == relockErrors && len(st.currErrs) == 0 {
   206  					st.cursorPos--
   207  				}
   208  			}
   209  		case key.Matches(msg, components.Keys.Down):
   210  			if st.getEffectiveCursor() < relockEnd-1 {
   211  				st.cursorPos++
   212  				if st.getEffectiveCursor() == relockErrors && len(st.currErrs) == 0 {
   213  					st.cursorPos++
   214  				}
   215  			}
   216  		}
   217  	}
   218  	var c tea.Cmd
   219  	st.spinner, c = st.spinner.Update(msg)
   220  	m.st = st
   221  
   222  	return m, tea.Batch(cmd, c)
   223  }
   224  
   225  func (st stateRelockResult) currentInfoView() (view components.ViewModel, canFocus bool) {
   226  	switch st.getEffectiveCursor() {
   227  	case relockRemaining: // remaining vulns
   228  		return st.vulnList, true
   229  	case relockUnfixable: // unfixable vulns
   230  		return st.unfixableList, true
   231  	case relockErrors:
   232  		return st.resolveErrors, false
   233  	case relockPatches: // one of the patches
   234  		return st.patchInfo[st.getPatchIndex()], true
   235  	case relockApply:
   236  		return components.TextView("Apply the selected patches and recompute vulnerabilities"), false
   237  	case relockWrite:
   238  		return components.TextView("Shell out to write manifest & lockfile"), false
   239  	case relockQuit:
   240  		return components.TextView("Exit Guided Remediation"), false
   241  	case relockEnd:
   242  		fallthrough
   243  	default:
   244  		return components.TextView(""), false // invalid (panic?)
   245  	}
   246  }
   247  
   248  func (st stateRelockResult) buildPatchInfoViews(m Model) stateRelockResult {
   249  	// create the info view for each of the patches
   250  	// and the unfixable vulns
   251  	st.patchInfo = nil
   252  	for i, p := range st.patches.Patches {
   253  		vulns := append(slices.Clone(st.currRes.Vulns), st.patches.Resolved[i].Vulns...)
   254  		st.patchInfo = append(st.patchInfo, components.NewRelockInfo(p, vulns, m.detailsRenderer))
   255  	}
   256  
   257  	unfixableVulns := relockUnfixableVulns(st.currRes.Vulns, st.patches.Patches)
   258  	st.unfixableList = components.NewVulnList(unfixableVulns, "", m.detailsRenderer)
   259  	st.numUnfixable = len(unfixableVulns)
   260  	return st.ResizeInfo(m.viewWidth, m.viewHeight).(stateRelockResult)
   261  }
   262  
   263  func relockUnfixableVulns(allVulns []resolution.Vulnerability, patches []result.Patch) []resolution.Vulnerability {
   264  	if len(allVulns) == 0 {
   265  		return nil
   266  	}
   267  	if len(patches) == 0 {
   268  		return allVulns
   269  	}
   270  
   271  	// find every vuln ID fixed in any patch
   272  	fixableVulnIDs := make(map[string]struct{})
   273  	for _, p := range patches {
   274  		for _, v := range p.Fixed {
   275  			fixableVulnIDs[v.ID] = struct{}{}
   276  		}
   277  	}
   278  	var unfixableVulns []resolution.Vulnerability
   279  	for _, v := range allVulns {
   280  		if _, ok := fixableVulnIDs[v.OSV.Id]; !ok {
   281  			unfixableVulns = append(unfixableVulns, v)
   282  		}
   283  	}
   284  	return unfixableVulns
   285  }
   286  
   287  func (st stateRelockResult) parseInput(m Model) (tea.Model, tea.Cmd) {
   288  	var cmd tea.Cmd
   289  	switch st.getEffectiveCursor() {
   290  	case relockRemaining: // vuln line, focus info view
   291  		st.focusedInfo = st.vulnList
   292  	case relockUnfixable: // unfixable vulns line, focus info view
   293  		st.focusedInfo = st.unfixableList
   294  	case relockPatches: // patch selected
   295  		idx := st.getPatchIndex()
   296  		if _, ok := st.selectedPatches[idx]; ok { // if already selected, deselect it
   297  			delete(st.selectedPatches, idx)
   298  		} else if st.patchCompatible(idx) { // if it's compatible with current other selections, select it
   299  			st.selectedPatches[idx] = struct{}{}
   300  		}
   301  	case relockApply: // apply changes
   302  		if len(st.selectedPatches) > 0 {
   303  			return st.relaxChoice(m)
   304  		}
   305  	case relockWrite: // write
   306  		m.writing = true
   307  		cmd = func() tea.Msg { return st.write(m) }
   308  	case relockQuit: // quit
   309  		cmd = tea.Quit
   310  	case relockErrors, relockEnd:
   311  	}
   312  
   313  	m.st = st
   314  	return m, cmd
   315  }
   316  
   317  func (st stateRelockResult) relaxChoice(m Model) (tea.Model, tea.Cmd) {
   318  	// Compute combined changes and re-resolve the graph
   319  	manifest := st.currRes.Manifest.Clone()
   320  	for i := range st.selectedPatches {
   321  		for _, p := range st.patches.Patches[i].PackageUpdates {
   322  			err := manifest.PatchRequirement(resolve.RequirementVersion{
   323  				VersionKey: resolve.VersionKey{
   324  					PackageKey: resolve.PackageKey{
   325  						Name:   p.Name,
   326  						System: m.manifestRW.System(),
   327  					},
   328  					Version:     p.VersionTo,
   329  					VersionType: resolve.Requirement,
   330  				},
   331  				Type: p.Type.Clone(),
   332  			})
   333  			if err != nil {
   334  				return errorAndExit(m, err)
   335  			}
   336  		}
   337  	}
   338  
   339  	st.currRes = nil
   340  	m.st = st
   341  	return m, doRelockCmd(m.options, manifest)
   342  }
   343  
   344  func (st stateRelockResult) View(m Model) string {
   345  	if m.writing {
   346  		return ""
   347  	}
   348  	s := strings.Builder{}
   349  	s.WriteString("RELOCK\n")
   350  	if st.currRes == nil {
   351  		s.WriteString("Resolving dependency graph ")
   352  		s.WriteString(st.spinner.View())
   353  		s.WriteString("\n")
   354  
   355  		return s.String()
   356  	}
   357  
   358  	s.WriteString(components.RenderSelectorOption(
   359  		st.getEffectiveCursor() == relockRemaining,
   360  		"",
   361  		"%s remain\n",
   362  		fmt.Sprintf("%d vulnerabilities", len(st.currRes.Vulns)),
   363  	))
   364  
   365  	if !st.patchesDone {
   366  		s.WriteString("\n")
   367  		s.WriteString("Computing possible patches ")
   368  		s.WriteString(st.spinner.View())
   369  		s.WriteString("\n")
   370  
   371  		return s.String()
   372  	}
   373  
   374  	s.WriteString(components.RenderSelectorOption(
   375  		st.getEffectiveCursor() == relockUnfixable,
   376  		"",
   377  		"%s are unfixable\n",
   378  		fmt.Sprintf("%d vulnerabilities", st.numUnfixable),
   379  	))
   380  
   381  	if len(st.currErrs) > 0 {
   382  		s.WriteString(components.RenderSelectorOption(
   383  			st.getEffectiveCursor() == relockErrors,
   384  			"",
   385  			"WARNING: Encountered %s during graph resolution.\n",
   386  			fmt.Sprintf("%d errors", len(st.currErrs)),
   387  		))
   388  	}
   389  	s.WriteString("\n")
   390  
   391  	if len(st.patches.Patches) == 0 {
   392  		s.WriteString("No remaining vulnerabilities can be fixed.\n")
   393  	} else {
   394  		s.WriteString("Actions:\n")
   395  		patchStrs := make([]string, len(st.patches.Patches))
   396  		for i, patch := range st.patches.Patches {
   397  			var checkBox string
   398  			if _, ok := st.selectedPatches[i]; ok {
   399  				checkBox = "[x]"
   400  			} else {
   401  				checkBox = "[ ]"
   402  			}
   403  			if !st.patchCompatible(i) {
   404  				checkBox = components.DisabledTextStyle.Render(checkBox)
   405  			}
   406  			checkBox = components.RenderSelectorOption(
   407  				st.getEffectiveCursor() == relockPatches && st.getPatchIndex() == i,
   408  				" > ",
   409  				"%s ",
   410  				checkBox,
   411  			)
   412  			text := patchString(patch)
   413  			var textSt lipgloss.Style
   414  			if st.patchCompatible(i) {
   415  				textSt = lipgloss.NewStyle()
   416  			} else {
   417  				textSt = components.DisabledTextStyle
   418  			}
   419  			text = textSt.Width(m.viewWidth - lipgloss.Width(checkBox)).Render(text)
   420  			patchStrs[i] = lipgloss.JoinHorizontal(lipgloss.Top, checkBox, text)
   421  		}
   422  		s.WriteString(lipgloss.JoinVertical(lipgloss.Left, patchStrs...))
   423  		s.WriteString("\n")
   424  
   425  		if len(st.selectedPatches) > 0 {
   426  			s.WriteString(components.RenderSelectorOption(
   427  				st.getEffectiveCursor() == relockApply,
   428  				"> ",
   429  				"%s pending patches\n",
   430  				"Apply",
   431  			))
   432  		} else {
   433  			s.WriteString(components.RenderSelectorOption(
   434  				st.getEffectiveCursor() == relockApply,
   435  				"> ",
   436  				components.DisabledTextStyle.Render("No pending patches")+"\n",
   437  			))
   438  		}
   439  	}
   440  
   441  	s.WriteString(components.RenderSelectorOption(
   442  		st.getEffectiveCursor() == relockWrite,
   443  		"> ",
   444  		"%s changes to manifest\n",
   445  		"Write",
   446  	))
   447  	s.WriteString("\n")
   448  	s.WriteString(components.RenderSelectorOption(
   449  		st.getEffectiveCursor() == relockQuit,
   450  		"> ",
   451  		"%s without saving changes\n",
   452  		"quit",
   453  	))
   454  
   455  	return s.String()
   456  }
   457  
   458  func patchString(patch result.Patch) string {
   459  	var depStr string
   460  	if len(patch.PackageUpdates) == 1 {
   461  		pkg := patch.PackageUpdates[0]
   462  		depStr = fmt.Sprintf("%s@%s → @%s", pkg.Name, pkg.VersionFrom, pkg.VersionTo)
   463  	} else {
   464  		depStr = fmt.Sprintf("%d packages", len(patch.PackageUpdates))
   465  	}
   466  	str := fmt.Sprintf("Upgrading %s resolves %d vulns", depStr, len(patch.Fixed))
   467  	if len(patch.Introduced) > 0 {
   468  		str += fmt.Sprintf(" but introduces %d new vulns", len(patch.Introduced))
   469  	}
   470  
   471  	return str
   472  }
   473  
   474  func (st stateRelockResult) InfoView() string {
   475  	v, _ := st.currentInfoView()
   476  	return v.View()
   477  }
   478  
   479  // check if a patch is compatible with the currently selected patches
   480  // i.e. if none of the direct dependencies in the current patch appear in the already selected patches
   481  func (st stateRelockResult) patchCompatible(idx int) bool {
   482  	if _, ok := st.selectedPatches[idx]; ok {
   483  		// already selected, it must be compatible
   484  		return true
   485  	}
   486  	// find any shared direct dependency packages
   487  	patch := st.patches.Patches[idx]
   488  	for i := range st.selectedPatches {
   489  		curr := st.patches.Patches[i]
   490  		for _, dep := range curr.PackageUpdates {
   491  			for _, newDep := range patch.PackageUpdates {
   492  				if dep.Name == newDep.Name {
   493  					return false
   494  				}
   495  			}
   496  		}
   497  	}
   498  
   499  	return true
   500  }
   501  
   502  func (st stateRelockResult) Resize(_, _ int) modelState {
   503  	return st
   504  }
   505  
   506  func (st stateRelockResult) ResizeInfo(w, h int) modelState {
   507  	st.vulnList = st.vulnList.Resize(w, h)
   508  	for i, info := range st.patchInfo {
   509  		st.patchInfo[i] = info.Resize(w, h)
   510  	}
   511  	return st
   512  }
   513  
   514  func (st stateRelockResult) IsInfoFocused() bool {
   515  	return st.focusedInfo != nil
   516  }
   517  
   518  func (st stateRelockResult) write(m Model) tea.Msg {
   519  	patches := remediation.ConstructPatches(m.relockBaseManifest, st.currRes)
   520  	err := parser.WriteManifestPatches(
   521  		m.options.Manifest,
   522  		m.relockBaseManifest.Manifest,
   523  		[]result.Patch{patches},
   524  		m.manifestRW,
   525  	)
   526  	if err != nil {
   527  		return writeMsg{err}
   528  	}
   529  
   530  	if m.options.Lockfile == "" {
   531  		// Unfortunately, there's no user feedback to show this was successful
   532  		return writeMsg{nil}
   533  	}
   534  
   535  	// shell out to npm to write the package-lock.json file.
   536  	dir := filepath.Dir(m.options.Manifest)
   537  	npmPath, err := exec.LookPath("npm")
   538  	if err != nil {
   539  		return writeMsg{fmt.Errorf("cannot find npm executable: %w", err)}
   540  	}
   541  
   542  	// Must remove preexisting package-lock.json and node_modules directory for a clean install.
   543  	// Use RemoveAll to avoid errors if the files doesn't exist.
   544  	if err := os.RemoveAll(filepath.Join(dir, "package-lock.json")); err != nil {
   545  		return fmt.Errorf("failed removing old package-lock.json/: %w", err)
   546  	}
   547  	if err := os.RemoveAll(filepath.Join(dir, "node_modules")); err != nil {
   548  		return fmt.Errorf("failed removing old node_modules/: %w", err)
   549  	}
   550  
   551  	c := exec.CommandContext(context.Background(), npmPath, "install", "--package-lock-only")
   552  	c.Dir = dir
   553  
   554  	return tea.ExecProcess(c, func(err error) tea.Msg {
   555  		if err != nil {
   556  			// try again with "--legacy-peer-deps"
   557  			c = exec.CommandContext(context.Background(), npmPath, "install", "--package-lock-only", "--legacy-peer-deps")
   558  			c.Dir = dir
   559  
   560  			return tea.ExecProcess(c, func(err error) tea.Msg { return writeMsg{err} })()
   561  		}
   562  
   563  		return writeMsg{err}
   564  	})()
   565  }
   566  
   567  func doRelockCmd(opts options.FixVulnsOptions, m manifest.Manifest) tea.Cmd {
   568  	return func() tea.Msg {
   569  		resolved, err := remediation.ResolveManifest(context.Background(), opts.ResolveClient, opts.VulnEnricher, m, &opts.RemediationOptions)
   570  		if err != nil {
   571  			return doRelockMsg{err: fmt.Errorf("failed resolving manifest vulnerabilities: %w", err)}
   572  		}
   573  		return doRelockMsg{resolvedManifest: resolved}
   574  	}
   575  }
   576  
   577  type relaxPatchMsg struct {
   578  	patches common.PatchResult
   579  	err     error
   580  }
   581  
   582  func doComputeRelaxPatchesCmd(opts options.FixVulnsOptions, resolved *remediation.ResolvedManifest) tea.Cmd {
   583  	return func() tea.Msg {
   584  		patches, err := relax.ComputePatches(context.Background(), opts.ResolveClient, opts.VulnEnricher, resolved, &opts.RemediationOptions)
   585  		if err != nil {
   586  			return relaxPatchMsg{err: fmt.Errorf("failed computing relax patches: %w", err)}
   587  		}
   588  		return relaxPatchMsg{patches: patches}
   589  	}
   590  }