cmd,internal/govulncheck: change Run to return error

Run is updated to return an error, instead of terminating inside the
function on error.

Change-Id: I66ea88a146a95f79090891a01fbca090569ead6e
Reviewed-on: https://go-review.googlesource.com/c/vuln/+/437781
TryBot-Result: Gopher Robot <gobot@golang.org>
Auto-Submit: Julie Qiu <julieqiu@google.com>
Reviewed-by: Hyang-Ah Hana Kim <hyangah@gmail.com>
Reviewed-by: Julie Qiu <julieqiu@google.com>
Run-TryBot: Julie Qiu <julieqiu@google.com>
diff --git a/cmd/govulncheck/main.go b/cmd/govulncheck/main.go
index 3c30502..c2aab15 100644
--- a/cmd/govulncheck/main.go
+++ b/cmd/govulncheck/main.go
@@ -5,6 +5,7 @@
 package main
 
 import (
+	"errors"
 	"flag"
 	"fmt"
 	"os"
@@ -73,7 +74,7 @@
 		buildFlags = []string{fmt.Sprintf("-tags=%s", strings.Join(tagsFlag, ","))}
 	}
 
-	govulncheck.Run(govulncheck.Config{
+	err := govulncheck.Run(govulncheck.Config{
 		AnalysisType: mode,
 		OutputType:   outputType,
 		Patterns:     patterns,
@@ -83,6 +84,19 @@
 			BuildFlags: buildFlags,
 		},
 	})
+	if outputType == govulncheck.OutputTypeJSON {
+		// The current behavior is to not print any errors.
+		return
+	}
+	if errors.Is(err, govulncheck.ErrContainsVulnerabilties) {
+		// This follows the style from
+		// golang.org/x/tools/go/analysis/singlechecker,
+		// which fails with 3 if there are findings (in this case, vulns).
+		os.Exit(3)
+	}
+	if err != nil {
+		die(fmt.Sprintf("govulncheck: %v", err))
+	}
 }
 
 func validateFlags(mode string) {
diff --git a/cmd/govulncheck/message.go b/cmd/govulncheck/message.go
new file mode 100644
index 0000000..61ff8af
--- /dev/null
+++ b/cmd/govulncheck/message.go
@@ -0,0 +1,41 @@
+// Copyright 2022 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 (
+	"fmt"
+
+	"golang.org/x/vuln/internal/govulncheck"
+)
+
+const (
+	noGoModErrorMessage = `govulncheck only works Go with modules. To make your project a module, run go mod init.
+
+See https://go.dev/doc/modules/managing-dependencies for more information.`
+
+	noGoSumErrorMessage = `Your module is missing a go.sum file. Try running go mod tidy.
+
+See https://go.dev/doc/modules/managing-dependencies for more information.`
+
+	goVersionMismatchErrorMessage = `Loading packages failed, possibly due to a mismatch between the Go version
+used to build govulncheck and the Go version on PATH. Consider rebuilding
+govulncheck with the current Go version.`
+)
+
+var errToMessage = map[error]string{
+	govulncheck.ErrNoGoMod:             noGoModErrorMessage,
+	govulncheck.ErrNoGoSum:             noGoSumErrorMessage,
+	govulncheck.ErrGoVersionMismatch:   goVersionMismatchErrorMessage,
+	govulncheck.ErrInvalidAnalysisType: "",
+	govulncheck.ErrInvalidOutputType:   "",
+}
+
+func messageForError(err error) (out string) {
+	msg, ok := errToMessage[err]
+	if !ok {
+		return ""
+	}
+	return fmt.Sprintf("govulncheck: %v\n\n%s", err, msg)
+}
diff --git a/exp/govulncheck/govulncheck.go b/exp/govulncheck/govulncheck.go
index a74a380..d9512ae 100644
--- a/exp/govulncheck/govulncheck.go
+++ b/exp/govulncheck/govulncheck.go
@@ -5,12 +5,14 @@
 // Package govulncheck has experimental govulncheck API.
 package govulncheck
 
-import "golang.org/x/vuln/internal/govulncheck"
+import (
+	"golang.org/x/vuln/internal/govulncheck"
+)
 
 // Config is the configuration for Main.
 type Config = govulncheck.Config
 
 // Run is the main function for the govulncheck command line tool.
-func Run(cfg Config) {
-	govulncheck.Run(cfg)
+func Run(cfg Config) error {
+	return govulncheck.Run(cfg)
 }
diff --git a/internal/govulncheck/errors.go b/internal/govulncheck/errors.go
index 5f993a8..ab63872 100644
--- a/internal/govulncheck/errors.go
+++ b/internal/govulncheck/errors.go
@@ -10,17 +10,37 @@
 	"strings"
 )
 
-const noGoModErrorMessage = `govulncheck: no go.mod file
+var (
+	// ErrContainsVulnerabilties is used to indicate that vulerabilities were
+	// found in the output of Run.
+	ErrContainsVulnerabilties = errors.New("module contains vulnerabilities")
+
+	// ErrInvalidAnalysisType indicates that an unsupported AnalysisType was passed to Config.
+	ErrInvalidAnalysisType = errors.New("invalid analysis type")
+
+	// ErrInvalidOutputType indicates that an unsupported OutputType was passed to Config.
+	ErrInvalidOutputType = errors.New("invalid output type")
+
+	// ErrErrGoVersionMismatch is used to indicate that there is a mismatch between
+	// the Go version used to build govulncheck and the one currently on PATH.
+	ErrGoVersionMismatch = errors.New(`Loading packages failed, possibly due to a mismatch between the Go version
+used to build govulncheck and the Go version on PATH. Consider rebuilding
+govulncheck with the current Go version.`)
+
+	// ErrNoGoSum indicates that a go.mod file was not found in this module.
+	ErrNoGoMod = errors.New(`no go.mod file
 
 govulncheck only works Go with modules. To make your project a module, run go mod init.
 
-See https://go.dev/doc/modules/managing-dependencies for more information.`
+See https://go.dev/doc/modules/managing-dependencies for more information.`)
 
-const noGoSumErrorMessage = `govulncheck: no go.sum file
+	// ErrNoGoSum indicates that a go.sum file was not found in this module.
+	ErrNoGoSum = errors.New(`no go.sum file
 
 Your module is missing a go.sum file. Try running go mod tidy.
 
-See https://go.dev/doc/modules/managing-dependencies for more information.`
+See https://go.dev/doc/modules/managing-dependencies for more information.`)
+)
 
 // fileExists checks if file path exists. Returns true
 // if the file exists or it cannot prove that it does
@@ -36,12 +56,6 @@
 	return true
 }
 
-const goVersionMismatchErrorMessage = `govulncheck: Go version mismatch
-
-Loading packages failed, possibly due to a mismatch between the Go version
-used to build govulncheck and the Go version on PATH. Consider rebuilding
-govulncheck with the current Go version.`
-
 // isGoVersionMismatchError checks if err is due to mismatch between
 // the Go version used to build govulncheck and the one currently
 // on PATH.
diff --git a/internal/govulncheck/run.go b/internal/govulncheck/run.go
index 6cd171d..2d98a2f 100644
--- a/internal/govulncheck/run.go
+++ b/internal/govulncheck/run.go
@@ -21,7 +21,7 @@
 )
 
 // Run is the main function for the govulncheck command line tool.
-func Run(cfg Config) {
+func Run(cfg Config) error {
 	dbs := []string{vulndbHost}
 	if db := os.Getenv(envGOVULNDB); db != "" {
 		dbs = strings.Split(db, ",")
@@ -30,7 +30,7 @@
 		HTTPCache: DefaultCache(),
 	})
 	if err != nil {
-		die("govulncheck: %s", err)
+		return err
 	}
 	vcfg := &vulncheck.Config{Client: dbClient, SourceGoVersion: internal.GoVersion()}
 
@@ -52,12 +52,12 @@
 	case AnalysisTypeBinary:
 		f, err := os.Open(patterns[0])
 		if err != nil {
-			die("govulncheck: %v", err)
+			return err
 		}
 		defer f.Close()
 		r, err = binary(ctx, f, vcfg)
 		if err != nil {
-			die("govulncheck: %v", err)
+			return err
 		}
 	case AnalysisTypeSource:
 		cfg := &cfg.SourceLoadConfig
@@ -65,13 +65,15 @@
 		if err != nil {
 			// Try to provide a meaningful and actionable error message.
 			if !fileExists(filepath.Join(cfg.Dir, "go.mod")) {
-				die(noGoModErrorMessage)
-			} else if !fileExists(filepath.Join(cfg.Dir, "go.sum")) {
-				die(noGoSumErrorMessage)
-			} else if isGoVersionMismatchError(err) {
-				die(fmt.Sprintf("%s\n\n%v", goVersionMismatchErrorMessage, err))
+				return ErrNoGoMod
 			}
-			die("govulncheck: %v", err)
+			if !fileExists(filepath.Join(cfg.Dir, "go.sum")) {
+				return ErrNoGoSum
+			}
+			if isGoVersionMismatchError(err) {
+				return fmt.Errorf("%v\n\n%v", ErrGoVersionMismatch, err)
+			}
+			return err
 		}
 
 		// Sort pkgs so that the PkgNodes returned by vulncheck.Source will be
@@ -79,48 +81,43 @@
 		sortPackages(pkgs)
 		r, err = vulncheck.Source(ctx, pkgs, vcfg)
 		if err != nil {
-			die("govulncheck: %v", err)
+			return err
 		}
 		unaffected = filterUnaffected(r)
 		r.Vulns = filterCalled(r)
 	default:
-		die("govulncheck: invalid analysis mode %q", cfg.AnalysisType)
+		return fmt.Errorf("%w: %s", ErrInvalidAnalysisType, cfg.AnalysisType)
 	}
 
 	switch format {
 	case OutputTypeJSON:
 		// Following golang.org/x/tools/go/analysis/singlechecker,
 		// return 0 exit code in -json mode.
-		writeJSON(r)
-		os.Exit(0)
+		return writeJSON(r)
 	case OutputTypeText, OutputTypeVerbose:
 		// set of top-level packages, used to find representative symbols
 		ci := GetCallInfo(r, pkgs)
 		writeText(r, ci, unaffected, format == OutputTypeVerbose)
 	case OutputTypeSummary:
 		ci := GetCallInfo(r, pkgs)
-		writeJSON(summary(ci, unaffected))
-		os.Exit(0)
+		return writeJSON(summary(ci, unaffected))
 	default:
-		die("govulncheck: unrecognized output type %q", cfg.OutputType)
+		return fmt.Errorf("%w: %s", ErrInvalidOutputType, cfg.OutputType)
 	}
-
-	// Following golang.org/x/tools/go/analysis/singlechecker,
-	// fail with 3 if there are findings (in this case, vulns).
-	exitCode := 0
 	if len(r.Vulns) > 0 {
-		exitCode = 3
+		return ErrContainsVulnerabilties
 	}
-	os.Exit(exitCode)
+	return nil
 }
 
-func writeJSON(r any) {
+func writeJSON(r any) error {
 	b, err := json.MarshalIndent(r, "", "\t")
 	if err != nil {
-		die("govulncheck: %s", err)
+		return err
 	}
 	os.Stdout.Write(b)
 	fmt.Println()
+	return nil
 }
 
 const (
@@ -316,11 +313,6 @@
 	return fmt.Sprintf("%s@%s", packagePath, v)
 }
 
-func die(format string, args ...interface{}) {
-	fmt.Fprintf(os.Stderr, format+"\n", args...)
-	os.Exit(1)
-}
-
 // indent returns the output of prefixing n spaces to s at every line break,
 // except for empty lines. See TestIndent for examples.
 func indent(s string, n int) string {