github.com/ActiveState/cli@v0.0.0-20240508170324-6801f60cd051/internal/runners/packages/info.go (about)

     1  package packages
     2  
     3  import (
     4  	"fmt"
     5  	"strconv"
     6  	"strings"
     7  
     8  	"github.com/ActiveState/cli/internal/captain"
     9  	"github.com/ActiveState/cli/internal/errs"
    10  	"github.com/ActiveState/cli/internal/locale"
    11  	"github.com/ActiveState/cli/internal/logging"
    12  	"github.com/ActiveState/cli/internal/multilog"
    13  	"github.com/ActiveState/cli/internal/output"
    14  	"github.com/ActiveState/cli/internal/rtutils/ptr"
    15  	"github.com/ActiveState/cli/pkg/platform/api/inventory/inventory_models"
    16  	"github.com/ActiveState/cli/pkg/platform/api/vulnerabilities/request"
    17  	"github.com/ActiveState/cli/pkg/platform/authentication"
    18  	"github.com/ActiveState/cli/pkg/platform/model"
    19  	"github.com/ActiveState/cli/pkg/project"
    20  	"github.com/go-openapi/strfmt"
    21  )
    22  
    23  // InfoRunParams tracks the info required for running Info.
    24  type InfoRunParams struct {
    25  	Package   captain.PackageValue
    26  	Timestamp captain.TimeValue
    27  	Language  string
    28  }
    29  
    30  // Info manages the information execution context.
    31  type Info struct {
    32  	out  output.Outputer
    33  	proj *project.Project
    34  	auth *authentication.Auth
    35  }
    36  
    37  // NewInfo prepares an information execution context for use.
    38  func NewInfo(prime primeable) *Info {
    39  	return &Info{
    40  		out:  prime.Output(),
    41  		proj: prime.Project(),
    42  		auth: prime.Auth(),
    43  	}
    44  }
    45  
    46  // Run executes the information behavior.
    47  func (i *Info) Run(params InfoRunParams, nstype model.NamespaceType) error {
    48  	logging.Debug("ExecuteInfo")
    49  
    50  	var nsTypeV *model.NamespaceType
    51  	var ns *model.Namespace
    52  
    53  	if params.Package.Namespace != "" {
    54  		ns = ptr.To(model.NewRawNamespace(params.Package.Namespace))
    55  	} else {
    56  		nsTypeV = &nstype
    57  	}
    58  
    59  	if nsTypeV != nil {
    60  		language, err := targetedLanguage(params.Language, i.proj, i.auth)
    61  		if err != nil {
    62  			return locale.WrapError(err, fmt.Sprintf("%s_err_cannot_obtain_language", *nsTypeV))
    63  		}
    64  		ns = ptr.To(model.NewNamespacePkgOrBundle(language, nstype))
    65  	}
    66  
    67  	normalized, err := model.FetchNormalizedName(*ns, params.Package.Name, i.auth)
    68  	if err != nil {
    69  		multilog.Error("Failed to normalize '%s': %v", params.Package.Name, err)
    70  		normalized = params.Package.Name
    71  	}
    72  
    73  	ts, err := getTime(&params.Timestamp, i.auth, i.proj)
    74  	if err != nil {
    75  		return errs.Wrap(err, "Unable to get timestamp from params")
    76  	}
    77  
    78  	packages, err := model.SearchIngredientsStrict(ns.String(), normalized, false, false, ts, i.auth) // ideally case-sensitive would be true (PB-4371)
    79  	if err != nil {
    80  		return locale.WrapError(err, "package_err_cannot_obtain_search_results")
    81  	}
    82  
    83  	if len(packages) == 0 {
    84  		return errs.AddTips(
    85  			locale.NewInputError("err_package_info_no_packages", "", params.Package.String()),
    86  			locale.T("package_try_search"),
    87  			locale.T("package_info_request"),
    88  		)
    89  	}
    90  
    91  	pkg := packages[0]
    92  	ingredientVersion := pkg.LatestVersion
    93  
    94  	if params.Package.Version != "" {
    95  		ingredientVersion, err = specificIngredientVersion(pkg.Ingredient.IngredientID, params.Package.Version, i.auth)
    96  		if err != nil {
    97  			return locale.WrapExternalError(err, "info_err_version_not_found", "Could not find version {{.V0}} for package {{.V1}}", params.Package.Version, params.Package.Name)
    98  		}
    99  	}
   100  
   101  	authors, err := model.FetchAuthors(pkg.Ingredient.IngredientID, ingredientVersion.IngredientVersionID, i.auth)
   102  	if err != nil {
   103  		return locale.WrapError(err, "package_err_cannot_obtain_authors_info", "Cannot obtain authors info")
   104  	}
   105  
   106  	var vulns []*model.VulnerabilityIngredient
   107  	if i.auth.Authenticated() {
   108  		vulnerabilityIngredients := make([]*request.Ingredient, len(pkg.Versions))
   109  		for i, p := range pkg.Versions {
   110  			vulnerabilityIngredients[i] = &request.Ingredient{
   111  				Name:      *pkg.Ingredient.Name,
   112  				Namespace: *pkg.Ingredient.PrimaryNamespace,
   113  				Version:   p.Version,
   114  			}
   115  		}
   116  
   117  		vulns, err = model.FetchVulnerabilitiesForIngredients(i.auth, vulnerabilityIngredients)
   118  		if err != nil {
   119  			return locale.WrapError(err, "package_err_cannot_obtain_vulnerabilities_info", "Cannot obtain vulnerabilities info")
   120  		}
   121  	}
   122  
   123  	i.out.Print(&infoOutput{i.out, structuredOutput{
   124  		pkg.Ingredient,
   125  		ingredientVersion,
   126  		authors,
   127  		pkg.Versions,
   128  		vulns,
   129  	}})
   130  
   131  	return nil
   132  }
   133  
   134  func specificIngredientVersion(ingredientID *strfmt.UUID, version string, auth *authentication.Auth) (*inventory_models.IngredientVersion, error) {
   135  	ingredientVersions, err := model.FetchIngredientVersions(ingredientID, auth)
   136  	if err != nil {
   137  		return nil, locale.WrapError(err, "info_err_cannot_obtain_version", "Could not retrieve ingredient version information")
   138  	}
   139  
   140  	for _, iv := range ingredientVersions {
   141  		if iv.Version != nil && *iv.Version == version {
   142  			return iv, nil
   143  		}
   144  	}
   145  
   146  	return nil, locale.NewInputError("err_no_ingredient_version_found", "No ingredient version found")
   147  }
   148  
   149  // PkgDetailsTable describes package details.
   150  type PkgDetailsTable struct {
   151  	Description string `opts:"omitEmpty" locale:"package_description,[HEADING]Description[/RESET]" json:"description"`
   152  	Author      string `opts:"omitEmpty" locale:"package_author,[HEADING]Author[/RESET]" json:"author"`
   153  	Authors     string `opts:"omitEmpty" locale:"package_authors,[HEADING]Authors[/RESET]" json:"authors"`
   154  	Website     string `opts:"omitEmpty" locale:"package_website,[HEADING]Website[/RESET]" json:"website"`
   155  	License     string `opts:"omitEmpty" locale:"package_license,[HEADING]License[/RESET]" json:"license"`
   156  }
   157  
   158  type infoResult struct {
   159  	name                 string
   160  	version              string
   161  	plainVersions        []string
   162  	PkgVersionVulnsTotal int      `opts:"omitEmpty" locale:"package_vulnerabilities,[HEADING]Vulnerabilities[/RESET]"`
   163  	PkgVersionVulns      []string `opts:"verticalTable,omitEmpty" locale:"package_cves,[HEADING]CVEs[/RESET]"`
   164  	*PkgDetailsTable     `locale:"," opts:"verticalTable,omitEmpty"`
   165  	Versions             []string `locale:"," json:"versions"`
   166  }
   167  
   168  func newInfoResult(so structuredOutput) *infoResult {
   169  	res := infoResult{
   170  		PkgDetailsTable: &PkgDetailsTable{},
   171  	}
   172  
   173  	if so.Ingredient.Name != nil {
   174  		res.name = *so.Ingredient.Name
   175  	}
   176  
   177  	if so.IngredientVersion.Version != nil {
   178  		res.version = *so.IngredientVersion.Version
   179  	}
   180  
   181  	if so.Ingredient.Description != nil {
   182  		res.PkgDetailsTable.Description = *so.Ingredient.Description
   183  	}
   184  
   185  	if so.Ingredient.Website != "" {
   186  		res.PkgDetailsTable.Website = so.Ingredient.Website.String()
   187  	}
   188  
   189  	if so.IngredientVersion.LicenseExpression != nil {
   190  		res.PkgDetailsTable.License = fmt.Sprintf("[CYAN]%s[/RESET]", *so.IngredientVersion.LicenseExpression)
   191  	}
   192  
   193  	for _, version := range so.Versions {
   194  		res.plainVersions = append(res.plainVersions, version.Version)
   195  	}
   196  
   197  	if len(so.Authors) == 1 {
   198  		if so.Authors[0].Name != nil {
   199  			res.Author = fmt.Sprintf("[CYAN]%s[/RESET]", *so.Authors[0].Name)
   200  		}
   201  	} else if len(so.Authors) > 1 {
   202  		var authorsOutput []string
   203  		for _, author := range so.Authors {
   204  			if author.Name != nil {
   205  				authorsOutput = append(authorsOutput, *author.Name)
   206  			}
   207  		}
   208  		res.Authors = fmt.Sprintf("[CYAN]%s[/RESET]", strings.Join(authorsOutput, ", "))
   209  	}
   210  
   211  	if len(so.Vulnerabilities) > 0 {
   212  		var currentVersionVulns *model.VulnerabilityIngredient
   213  		alternateVersionsVulns := make(map[string]*model.VulnerabilityIngredient)
   214  		// Iterate over the vulnerabilities to populate the maps above.
   215  		for _, v := range so.Vulnerabilities {
   216  			alternateVersionsVulns[v.Version] = v
   217  			if v.Version == res.version {
   218  				currentVersionVulns = v
   219  			}
   220  		}
   221  
   222  		if currentVersionVulns != nil {
   223  			res.PkgVersionVulnsTotal = currentVersionVulns.Vulnerabilities.Length()
   224  			// Build the vulnerabilities output for the specific version requested.
   225  			// This is organized by severity level.
   226  			if len(currentVersionVulns.Vulnerabilities.Critical) > 0 {
   227  				criticalOutput := fmt.Sprintf("[RED]%d Critical: [/RESET]", len(currentVersionVulns.Vulnerabilities.Critical))
   228  				criticalOutput += fmt.Sprintf("[CYAN]%s[/RESET]", strings.Join(currentVersionVulns.Vulnerabilities.Critical, ", "))
   229  				res.PkgVersionVulns = append(res.PkgVersionVulns, criticalOutput)
   230  			}
   231  
   232  			if len(currentVersionVulns.Vulnerabilities.High) > 0 {
   233  				highOutput := fmt.Sprintf("[ORANGE]%d High: [/RESET]", len(currentVersionVulns.Vulnerabilities.High))
   234  				highOutput += fmt.Sprintf("[CYAN]%s[/RESET]", strings.Join(currentVersionVulns.Vulnerabilities.High, ", "))
   235  				res.PkgVersionVulns = append(res.PkgVersionVulns, highOutput)
   236  			}
   237  
   238  			if len(currentVersionVulns.Vulnerabilities.Medium) > 0 {
   239  				mediumOutput := fmt.Sprintf("[YELLOW]%d Medium: [/RESET]", len(currentVersionVulns.Vulnerabilities.Medium))
   240  				mediumOutput += fmt.Sprintf("[CYAN]%s[/RESET]", strings.Join(currentVersionVulns.Vulnerabilities.Medium, ", "))
   241  				res.PkgVersionVulns = append(res.PkgVersionVulns, mediumOutput)
   242  			}
   243  
   244  			if len(currentVersionVulns.Vulnerabilities.Low) > 0 {
   245  				lowOutput := fmt.Sprintf("[MAGENTA]%d Low: [/RESET]", len(currentVersionVulns.Vulnerabilities.Low))
   246  				lowOutput += fmt.Sprintf("[CYAN]%s[/RESET]", strings.Join(currentVersionVulns.Vulnerabilities.Low, ", "))
   247  				res.PkgVersionVulns = append(res.PkgVersionVulns, lowOutput)
   248  			}
   249  		}
   250  
   251  		// Build the output for the alternate versions of this package.
   252  		// This output counts the number of vulnerabilities per severity level.
   253  		for _, version := range so.Versions {
   254  			alternateVersion, ok := alternateVersionsVulns[version.Version]
   255  			if !ok {
   256  				res.Versions = append(res.Versions, fmt.Sprintf("[GREEN]%s[/RESET]", version.Version))
   257  				continue
   258  			}
   259  
   260  			var vulnTotals []string
   261  			if len(alternateVersion.Vulnerabilities.Critical) > 0 {
   262  				vulnTotals = append(vulnTotals, fmt.Sprintf("[RED]%d Critical[/RESET]", len(alternateVersion.Vulnerabilities.Critical)))
   263  			}
   264  			if len(alternateVersion.Vulnerabilities.High) > 0 {
   265  				vulnTotals = append(vulnTotals, fmt.Sprintf("[ORANGE]%d High[/RESET]", len(alternateVersion.Vulnerabilities.High)))
   266  			}
   267  			if len(alternateVersion.Vulnerabilities.Medium) > 0 {
   268  				vulnTotals = append(vulnTotals, fmt.Sprintf("[YELLOW]%d Medium[/RESET]", len(alternateVersion.Vulnerabilities.Medium)))
   269  			}
   270  			if len(alternateVersion.Vulnerabilities.Low) > 0 {
   271  				vulnTotals = append(vulnTotals, fmt.Sprintf("[MAGENTA]%d Low[/RESET]", len(alternateVersion.Vulnerabilities.Low)))
   272  			}
   273  
   274  			output := fmt.Sprintf("%s (CVE: %s)", version.Version, strings.Join(vulnTotals, ", "))
   275  			res.Versions = append(res.Versions, output)
   276  		}
   277  	} else {
   278  		// If we do not have vulnerability information, we still want to display the available versions.
   279  		for _, version := range so.Versions {
   280  			res.Versions = append(res.Versions, version.Version)
   281  		}
   282  	}
   283  
   284  	return &res
   285  }
   286  
   287  type structuredOutput struct {
   288  	Ingredient        *inventory_models.Ingredient                         `json:"ingredient"`
   289  	IngredientVersion *inventory_models.IngredientVersion                  `json:"ingredient_version"`
   290  	Authors           model.Authors                                        `json:"authors"`
   291  	Versions          []*inventory_models.SearchIngredientsResponseVersion `json:"versions"`
   292  	Vulnerabilities   []*model.VulnerabilityIngredient                     `json:"vulnerabilities,omitempty"`
   293  }
   294  
   295  type infoOutput struct {
   296  	out output.Outputer
   297  	so  structuredOutput
   298  }
   299  
   300  func (o *infoOutput) MarshalOutput(_ output.Format) interface{} {
   301  	res := newInfoResult(o.so)
   302  	print := o.out.Print
   303  	{
   304  		print(output.Title(
   305  			locale.Tl(
   306  				"package_info_description_header",
   307  				"[HEADING]Package Information:[/RESET] [CYAN]{{.V0}}@{{.V1}}[/RESET]",
   308  				res.name,
   309  				res.version,
   310  			),
   311  		))
   312  		print(
   313  			struct {
   314  				*PkgDetailsTable `opts:"verticalTable"`
   315  			}{res.PkgDetailsTable},
   316  		)
   317  		print("")
   318  	}
   319  
   320  	{
   321  		if res.PkgVersionVulnsTotal > 0 {
   322  			print(output.Title(
   323  				locale.Tl(
   324  					"package_info_vulnerabilities_header",
   325  					"[HEADING]This package has {{.V0}} Vulnerabilities (CVEs):[/RESET]",
   326  					strconv.Itoa(res.PkgVersionVulnsTotal),
   327  				),
   328  			))
   329  			print(res.PkgVersionVulns)
   330  			print("")
   331  			print(locale.Tl("package_info_vulnerabilities_help", "  To view details for these CVE's run '[ACTIONABLE]state cve open <ID>[/RESET]'"))
   332  			print("")
   333  		}
   334  	}
   335  
   336  	{
   337  		if len(res.Versions) > 0 {
   338  			print(output.Title(
   339  				locale.Tl(
   340  					"packages_info_versions_available",
   341  					"{{.V0}} Version(s) Available:",
   342  					strconv.Itoa(len(res.Versions)),
   343  				),
   344  			))
   345  			print(res.Versions)
   346  			print("")
   347  		}
   348  	}
   349  
   350  	{
   351  		print(output.Title(locale.Tl("packages_info_next_header", "What's next?")))
   352  		print(whatsNextMessages(res.name, res.plainVersions))
   353  	}
   354  
   355  	return output.Suppress
   356  }
   357  
   358  func (o *infoOutput) MarshalStructured(_ output.Format) interface{} {
   359  	return o.so
   360  }
   361  
   362  func whatsNextMessages(name string, versions []string) []string {
   363  	nextMsgs := make([]string, 0, 3)
   364  
   365  	nextMsgs = append(nextMsgs,
   366  		locale.Tl(
   367  			"install_latest_version",
   368  			"To install the latest version, run "+
   369  				"'[ACTIONABLE]state install {{.V0}}[/RESET]'",
   370  			name,
   371  		),
   372  	)
   373  
   374  	if len(versions) == 0 {
   375  		return nextMsgs
   376  	}
   377  	version := versions[0]
   378  
   379  	nextMsgs = append(nextMsgs,
   380  		locale.Tl(
   381  			"install_specific_version",
   382  			"To install a specific version, run "+
   383  				"'[ACTIONABLE]state install {{.V0}}@{{.V1}}[/RESET]'",
   384  			name, version,
   385  		),
   386  	)
   387  
   388  	if len(versions) > 1 {
   389  		version = versions[1]
   390  	}
   391  	nextMsgs = append(nextMsgs,
   392  		locale.Tl(
   393  			"show_specific_version",
   394  			"To view details for a specific version, run "+
   395  				"'[ACTIONABLE]state info {{.V0}}@{{.V1}}[/RESET]'",
   396  			name, version,
   397  		),
   398  	)
   399  
   400  	return nextMsgs
   401  }