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 }