blob: e444a32c2ede4d54e265df93dfef33761eca8a5d [file] [log] [blame]
// Copyright 2024 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package main
import (
"context"
"errors"
"fmt"
"io/fs"
"golang.org/x/vulndb/cmd/vulnreport/log"
"golang.org/x/vulndb/internal/issues"
"golang.org/x/vulndb/internal/report"
)
// command represents a subcommand of vulnreport.
type command interface {
// name outputs the string used to invoke the subcommand.
name() string
// usage outputs strings indicating how to use the subcommand.
usage() (args string, desc string)
setuper
// parseArgs takes in the raw args passed to the command line,
// and converts them to a representation understood by "run".
// This function need not be one-to-one: there may be more
// inputs than args or vice-versa.
parseArgs(_ context.Context, args []string) (inputs []string, _ error)
lookup(context.Context, string) (any, error)
skip(any) string
run(context.Context, any) error
// close cleans up state and/or completes tasks that should occur
// after run is called on all inputs.
close() error
inputType() string
}
// run executes the given command on the given raw arguments.
func run(ctx context.Context, c command, args []string, env environment) (err error) {
if err := c.setup(ctx, env); err != nil {
return err
}
stats := &counter{}
defer func() {
if cerr := c.close(); cerr != nil {
err = errors.Join(err, cerr)
}
if total := stats.total(); total > 0 {
log.Infof("%s: processed %d %s(s) (success=%d; skip=%d; error=%d)", c.name(), total, c.inputType(), stats.succeeded, stats.skipped, stats.errored)
}
if stats.errored > 0 {
err = errors.Join(err, fmt.Errorf("errored on %d inputs", stats.errored))
}
}()
inputs, err := c.parseArgs(ctx, args)
if err != nil {
return err
}
log.Infof("%s: operating on %d %s(s)", c.name(), len(inputs), c.inputType())
for _, input := range inputs {
in, err := c.lookup(ctx, input)
if err != nil {
stats.errored++
log.Errf("%s: lookup %s failed: %s", c.name(), input, err)
continue
}
if reason := c.skip(in); reason != "" {
stats.skipped++
log.Infof("%s: skipping %s (%s)", c.name(), toString(in), reason)
continue
}
log.Infof("%s %s", c.name(), input)
if err := c.run(ctx, in); err != nil {
stats.errored++
log.Errf("%s: %s", c.name(), err)
continue
}
stats.succeeded++
}
return nil
}
type counter struct {
skipped int
succeeded int
errored int
}
func (c *counter) total() int {
return c.skipped + c.succeeded + c.errored
}
func toString(in any) string {
switch v := in.(type) {
case *yamlReport:
return fmt.Sprintf("report %s", v.Report.ID)
case *issues.Issue:
return fmt.Sprintf("issue #%d", v.Number)
default:
return fmt.Sprintf("%v", v)
}
}
type setuper interface {
// setup populates state needed to run a command.
setup(context.Context, environment) error
}
func setupAll(ctx context.Context, env environment, fs ...setuper) error {
for _, f := range fs {
if err := f.setup(ctx, env); err != nil {
return err
}
}
return nil
}
type closer interface {
close() error
}
func closeAll(cs ...closer) error {
for _, c := range cs {
if err := c.close(); err != nil {
return err
}
}
return nil
}
const (
filenameArgs = "[filename | github-id] ..."
ghIssueArgs = "[github-id] ..."
)
// filenameParser implements the "parseArgs" function of the command
// interface, and can be used by commands that operate on YAML filenames.
type filenameParser struct {
fsys fs.FS
}
func (f *filenameParser) setup(_ context.Context, env environment) error {
f.fsys = env.ReportFS()
return nil
}
func (*filenameParser) inputType() string {
return "report"
}
func (f *filenameParser) parseArgs(_ context.Context, args []string) (filenames []string, allErrs error) {
if len(args) == 0 {
return nil, fmt.Errorf("no arguments provided")
}
for _, arg := range args {
fname, err := argToFilename(arg, f.fsys)
if err != nil {
log.Err(err)
continue
}
filenames = append(filenames, fname)
}
if len(filenames) == 0 {
return nil, fmt.Errorf("could not parse any valid filenames from arguments")
}
return filenames, nil
}
func argToFilename(arg string, fsys fs.FS) (string, error) {
if _, err := fs.Stat(fsys, arg); err != nil {
// If arg isn't a file, see if it might be an issue ID
// with an existing report.
for _, padding := range []string{"", "0", "00", "000"} {
m, _ := fs.Glob(fsys, "data/*/GO-*-"+padding+arg+".yaml")
if len(m) == 1 {
return m[0], nil
}
}
return "", fmt.Errorf("could not parse argument %q: not a valid filename or issue ID with existing report: %w", arg, err)
}
return arg, nil
}
func (f *filenameParser) lookup(_ context.Context, filename string) (any, error) {
r, err := report.ReadStrict(f.fsys, filename)
if err != nil {
return nil, err
}
return &yamlReport{Report: r, Filename: filename}, nil
}
type noSkip bool
func (noSkip) skip(any) string {
return ""
}
type yamlReport struct {
*report.Report
Filename string
}