| // 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 | |
| } |