github.com/wolfi-dev/wolfictl@v0.16.11/pkg/cli/components/advisory/prompt/model.go (about) 1 package prompt 2 3 import ( 4 "errors" 5 6 "github.com/charmbracelet/bubbles/textinput" 7 tea "github.com/charmbracelet/bubbletea" 8 "github.com/wolfi-dev/wolfictl/pkg/advisory" 9 "github.com/wolfi-dev/wolfictl/pkg/cli/components/advisory/field" 10 v2 "github.com/wolfi-dev/wolfictl/pkg/configs/advisory/v2" 11 "github.com/wolfi-dev/wolfictl/pkg/vuln" 12 ) 13 14 type Model struct { 15 // internal data 16 focusIndex int 17 fields []field.Field 18 allowedPackagesFunc func() []string 19 allowedVulnerabilitiesFunc func(packageName string) []string 20 allowedFixedVersionsFunc func(packageName string) []string 21 22 // input/output data 23 Request advisory.Request 24 25 // output data 26 27 // EarlyExit is set to true if the user asks to exit the prompt early. 28 EarlyExit bool 29 } 30 31 const ( 32 fieldIDPackage = "package" 33 fieldIDVulnerability = "vulnerability" 34 fieldIDEventType = "event-type" 35 fieldIDFixedVersion = "fixed-version" 36 fieldIDTruePositiveNote = "true-positive-note" 37 fieldIDFalsePositiveType = "false-positive-type" 38 fieldIDFalsePositiveNote = "false-positive-note" 39 fieldIDFixNotPlannedNote = "fix-not-planned-note" 40 fieldIDAnalysisNotPlannedNote = "analysis-not-planned-note" 41 fieldIDPendingUpstreamFixNote = "pending-upstream-fix-note" 42 ) 43 44 func (m Model) newPackageFieldConfig() field.TextFieldConfiguration { 45 allowedValues := m.allowedPackagesFunc() 46 47 return field.TextFieldConfiguration{ 48 ID: fieldIDPackage, 49 Prompt: "Package: ", 50 RequestUpdater: func(value string, req advisory.Request) advisory.Request { 51 req.Package = value 52 return req 53 }, 54 AllowedValues: allowedValues, 55 EmptyValueHelpMsg: "Type to find a package.", 56 NoMatchHelpMsg: "No matching package found.", 57 ValidationRules: []field.TextValidationRule{ 58 field.NotEmpty, 59 }, 60 } 61 } 62 63 var validCVEID = field.TextValidationRule(vuln.ValidateID) 64 65 func (m Model) newVulnerabilityFieldConfig() field.TextFieldConfiguration { 66 allowedValues := m.allowedVulnerabilitiesFunc(m.Request.Package) 67 68 return field.TextFieldConfiguration{ 69 ID: fieldIDVulnerability, 70 Prompt: "Vulnerability: ", 71 RequestUpdater: func(value string, req advisory.Request) advisory.Request { 72 req.VulnerabilityID = value 73 return req 74 }, 75 EmptyValueHelpMsg: "Provide a valid vulnerability ID.", 76 ValidationRules: []field.TextValidationRule{ 77 field.NotEmpty, 78 validCVEID, 79 }, 80 AllowedValues: allowedValues, 81 } 82 } 83 84 func (m Model) newTypeFieldConfig() field.ListFieldConfiguration { 85 return field.ListFieldConfiguration{ 86 ID: fieldIDEventType, 87 Prompt: "Type: ", 88 Options: v2.EventTypes, 89 RequestUpdater: func(value string, req advisory.Request) advisory.Request { 90 req.Event.Type = value 91 return req 92 }, 93 } 94 } 95 96 func (m Model) newTruePositiveNoteFieldConfig() field.TextFieldConfiguration { 97 return field.TextFieldConfiguration{ 98 ID: fieldIDTruePositiveNote, 99 Prompt: "Note: ", 100 RequestUpdater: func(value string, req advisory.Request) advisory.Request { 101 if value == "" { 102 req.Event.Data = nil 103 return req 104 } 105 106 if req.Event.Data == nil { 107 req.Event.Data = v2.TruePositiveDetermination{ 108 Note: value, 109 } 110 } 111 112 return req 113 }, 114 } 115 } 116 117 func (m Model) newFixNotPlannedNoteFieldConfig() field.TextFieldConfiguration { 118 return field.TextFieldConfiguration{ 119 ID: fieldIDFixNotPlannedNote, 120 Prompt: "Note: ", 121 RequestUpdater: func(value string, req advisory.Request) advisory.Request { 122 if req.Event.Data == nil { 123 req.Event.Data = v2.FixNotPlanned{ 124 Note: value, 125 } 126 } else if data, ok := req.Event.Data.(v2.FixNotPlanned); ok { 127 data.Note = value 128 req.Event.Data = data 129 } 130 return req 131 }, 132 ValidationRules: []field.TextValidationRule{ 133 field.NotEmpty, 134 }, 135 } 136 } 137 138 func (m Model) newAnalysisNotPlannedNoteFieldConfig() field.TextFieldConfiguration { 139 return field.TextFieldConfiguration{ 140 ID: fieldIDAnalysisNotPlannedNote, 141 Prompt: "Note: ", 142 RequestUpdater: func(value string, req advisory.Request) advisory.Request { 143 if req.Event.Data == nil { 144 req.Event.Data = v2.AnalysisNotPlanned{ 145 Note: value, 146 } 147 } else if data, ok := req.Event.Data.(v2.AnalysisNotPlanned); ok { 148 data.Note = value 149 req.Event.Data = data 150 } 151 return req 152 }, 153 ValidationRules: []field.TextValidationRule{ 154 field.NotEmpty, 155 }, 156 } 157 } 158 159 func (m Model) newPendingUpstreamReleaseNoteFieldConfig() field.TextFieldConfiguration { 160 return field.TextFieldConfiguration{ 161 ID: fieldIDPendingUpstreamFixNote, 162 Prompt: "Note: ", 163 RequestUpdater: func(value string, req advisory.Request) advisory.Request { 164 if req.Event.Data == nil { 165 req.Event.Data = v2.PendingUpstreamFix{ 166 Note: value, 167 } 168 } else if data, ok := req.Event.Data.(v2.PendingUpstreamFix); ok { 169 data.Note = value 170 req.Event.Data = data 171 } 172 return req 173 }, 174 ValidationRules: []field.TextValidationRule{ 175 field.NotEmpty, 176 }, 177 } 178 } 179 180 func (m Model) newFalsePositiveNoteFieldConfig() field.TextFieldConfiguration { 181 return field.TextFieldConfiguration{ 182 ID: fieldIDFalsePositiveNote, 183 Prompt: "Note: ", 184 RequestUpdater: func(value string, req advisory.Request) advisory.Request { 185 if req.Event.Data == nil { 186 req.Event.Data = v2.FalsePositiveDetermination{ 187 Note: value, 188 } 189 } else if data, ok := req.Event.Data.(v2.FalsePositiveDetermination); ok { 190 data.Note = value 191 req.Event.Data = data 192 } 193 return req 194 }, 195 ValidationRules: []field.TextValidationRule{ 196 field.NotEmpty, 197 }, 198 } 199 } 200 201 func (m Model) newFalsePositiveTypeFieldConfig() field.ListFieldConfiguration { 202 return field.ListFieldConfiguration{ 203 ID: fieldIDFalsePositiveType, 204 Prompt: "False Positive Type: ", 205 Options: v2.FPTypes, 206 RequestUpdater: func(value string, req advisory.Request) advisory.Request { 207 if req.Event.Data == nil { 208 req.Event.Data = v2.FalsePositiveDetermination{ 209 Type: value, 210 } 211 } else if data, ok := req.Event.Data.(v2.FalsePositiveDetermination); ok { 212 data.Type = value 213 req.Event.Data = data 214 } 215 return req 216 }, 217 } 218 } 219 220 func (m Model) newFixedVersionFieldConfig(packageName string) field.TextFieldConfiguration { 221 allowedVersions := m.allowedFixedVersionsFunc(packageName) 222 223 cfg := field.TextFieldConfiguration{ 224 ID: fieldIDFixedVersion, 225 Prompt: "Fixed Version: ", 226 RequestUpdater: func(value string, req advisory.Request) advisory.Request { 227 if req.Event.Data == nil { 228 req.Event.Data = v2.Fixed{ 229 FixedVersion: value, 230 } 231 } 232 return req 233 }, 234 AllowedValues: allowedVersions, 235 NoMatchHelpMsg: "No matching version found.", 236 } 237 238 if len(allowedVersions) >= 1 { 239 cfg.DefaultSuggestion = allowedVersions[0] 240 } 241 242 return cfg 243 } 244 245 func (m Model) hasFieldWithID(id string) bool { 246 for _, f := range m.fields { 247 if f.ID() == id { 248 return true 249 } 250 } 251 252 return false 253 } 254 255 type Configuration struct { 256 Request advisory.Request 257 AllowedPackagesFunc func() []string 258 AllowedVulnerabilitiesFunc func(packageName string) []string 259 AllowedFixedVersionsFunc func(packageName string) []string 260 } 261 262 func New(config Configuration) Model { 263 m := Model{ 264 Request: config.Request, 265 266 allowedPackagesFunc: config.AllowedPackagesFunc, 267 allowedVulnerabilitiesFunc: config.AllowedVulnerabilitiesFunc, 268 allowedFixedVersionsFunc: config.AllowedFixedVersionsFunc, 269 } 270 271 m, _ = m.addMissingFields() 272 273 m.fields[0], _ = m.fields[0].SetFocus() 274 275 return m 276 } 277 278 // addMissingFields returns an updated model, and a bool indicating whether any 279 // fields needed to be added. 280 func (m Model) addMissingFields() (Model, bool) { 281 if m.Request.Package == "" { 282 f := field.NewTextField(m.newPackageFieldConfig()) 283 m.fields = append(m.fields, f) 284 return m, true 285 } 286 287 if m.Request.VulnerabilityID == "" { 288 f := field.NewTextField(m.newVulnerabilityFieldConfig()) 289 m.fields = append(m.fields, f) 290 return m, true 291 } 292 293 if m.Request.Event.Type == "" { 294 f := field.NewListField(m.newTypeFieldConfig()) 295 m.fields = append(m.fields, f) 296 return m, true 297 } 298 299 switch e := m.Request.Event; e.Type { 300 case v2.EventTypeFixed: 301 if data, ok := e.Data.(v2.Fixed); !ok || data.FixedVersion == "" { 302 f := field.NewTextField(m.newFixedVersionFieldConfig(m.Request.Package)) 303 m.fields = append(m.fields, f) 304 return m, true 305 } 306 307 case v2.EventTypeTruePositiveDetermination: 308 // This field is optional. If we've already asked for it, don't ask again. 309 if m.hasFieldWithID(fieldIDTruePositiveNote) { 310 return m, false 311 } 312 313 if _, ok := e.Data.(v2.TruePositiveDetermination); !ok { 314 f := field.NewTextField(m.newTruePositiveNoteFieldConfig()) 315 m.fields = append(m.fields, f) 316 return m, true 317 } 318 319 case v2.EventTypeFalsePositiveDetermination: 320 if data, ok := e.Data.(v2.FalsePositiveDetermination); !ok || data.Type == "" { 321 f := field.NewListField(m.newFalsePositiveTypeFieldConfig()) 322 m.fields = append(m.fields, f) 323 return m, true 324 } 325 326 if data, ok := e.Data.(v2.FalsePositiveDetermination); !ok || data.Note == "" { 327 f := field.NewTextField(m.newFalsePositiveNoteFieldConfig()) 328 m.fields = append(m.fields, f) 329 return m, true 330 } 331 332 case v2.EventTypeFixNotPlanned: 333 if data, ok := e.Data.(v2.FixNotPlanned); !ok || data.Note == "" { 334 f := field.NewTextField(m.newFixNotPlannedNoteFieldConfig()) 335 m.fields = append(m.fields, f) 336 return m, true 337 } 338 339 case v2.EventTypeAnalysisNotPlanned: 340 if data, ok := e.Data.(v2.AnalysisNotPlanned); !ok || data.Note == "" { 341 f := field.NewTextField(m.newAnalysisNotPlannedNoteFieldConfig()) 342 m.fields = append(m.fields, f) 343 return m, true 344 } 345 346 case v2.EventTypePendingUpstreamFix: 347 if data, ok := e.Data.(v2.PendingUpstreamFix); !ok || data.Note == "" { 348 f := field.NewTextField(m.newPendingUpstreamReleaseNoteFieldConfig()) 349 m.fields = append(m.fields, f) 350 return m, true 351 } 352 } 353 354 return m, false 355 } 356 357 func (m Model) Init() tea.Cmd { 358 return textinput.Blink 359 } 360 361 func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 362 if msg, ok := msg.(tea.KeyMsg); ok { 363 switch msg.String() { 364 case "ctrl+c": 365 m.EarlyExit = true 366 return m, tea.Quit 367 368 case "enter": 369 // Handle field entry completion 370 371 sel := m.fields[m.focusIndex] 372 373 sel, err := sel.SubmitValue() 374 if err != nil { 375 var inner field.ErrValueNotAccepted 376 if errors.As(err, &inner) { 377 // Value isn't ready to be submitted; do nothing. 378 return m, nil 379 } 380 381 // TODO: Handle other errors 382 } 383 384 m.Request = sel.UpdateRequest(m.Request) 385 386 // We should move this business logic snippet somewhere else eventually. 387 if m.Request.Event.Type == v2.EventTypeDetection { 388 m.Request.Event.Data = v2.Detection{Type: v2.DetectionTypeManual} 389 } 390 391 m.fields[m.focusIndex] = sel 392 393 // Move on to the next field 394 395 m.focusIndex++ 396 397 var moreFieldsAdded bool 398 m, moreFieldsAdded = m.addMissingFields() 399 400 if !moreFieldsAdded { 401 if m.focusIndex == len(m.fields) { 402 return m, tea.Quit 403 } 404 } 405 406 cmds := make([]tea.Cmd, len(m.fields)) 407 for i := 0; i <= len(m.fields)-1; i++ { 408 if i == m.focusIndex { 409 m.fields[i], cmds[i] = m.fields[i].SetFocus() 410 continue 411 } 412 413 m.fields[i] = m.fields[i].SetBlur() 414 } 415 416 return m, tea.Batch(cmds...) 417 } 418 } 419 420 // Handle character input and blinking 421 m, cmd := m.updateFields(msg) 422 423 return m, cmd 424 } 425 426 func (m Model) View() string { 427 view := "" 428 429 for _, f := range m.fields { 430 view += f.View() + "\n" 431 432 if !f.IsDone() { 433 // Don't show more than one "not done" field at a time 434 break 435 } 436 } 437 438 return view 439 } 440 441 func (m Model) updateFields(msg tea.Msg) (Model, tea.Cmd) { 442 cmds := make([]tea.Cmd, len(m.fields)) 443 444 // Only text inputs with Focus() set will respond, so it's safe to simply 445 // update all of them here without any further logic. 446 for i := range m.fields { 447 m.fields[i], cmds[i] = m.fields[i].Update(msg) 448 } 449 450 return m, tea.Batch(cmds...) 451 }