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 }