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  }