diff --git a/README.md b/README.md index 68a9e27..f7cd346 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,54 @@ go get github.com/github/go-spdx@latest - [spdxexp](https://pkg.go.dev/github.com/github/go-spdx/spdxexp) - Expression package validates licenses and determines if a license expression is satisfied by a list of licenses. Validity of a license is determined by the SPDX license list. +## CLI: spdx-validate + +`spdx-validate` is a command-line tool that validates SPDX license expressions. + +### Building + +```sh +go build -o spdx-validate ./cmd/spdx-validate/ +``` + +### Usage + +**Validate expressions on stdin:** + +Stdin is read as a stream of newline-separated expressions: + +```sh +echo "MIT" | ./spdx-validate +printf "MIT\nApache-2.0\nBSD-3-Clause\n" | ./spdx-validate +``` + +Exits with code 0 if all expressions are valid, or code 1 (with error messages on stderr) if any are invalid. + +```sh +$ printf "MIT\nBOGUS\nApache-2.0\n" | ./spdx-validate +line 2: invalid SPDX expression: "BOGUS" +1 of 3 expressions failed validation +``` + +**Validate from a file with `-f`/`--file`:** + +```sh +./spdx-validate -f licenses.txt +``` + +The file should contain one SPDX expression per line. Blank lines are skipped. + +```sh +$ cat licenses.txt +MIT +NOT-A-LICENSE +Apache-2.0 + +$ ./spdx-validate -f licenses.txt +line 2: invalid SPDX expression: "NOT-A-LICENSE" +1 of 3 expressions failed validation +``` + ## Public API _NOTE: The public API is initially limited to the Satisfies and ValidateLicenses functions. If diff --git a/cmd/spdx-validate/main.go b/cmd/spdx-validate/main.go new file mode 100644 index 0000000..d31bf2d --- /dev/null +++ b/cmd/spdx-validate/main.go @@ -0,0 +1,99 @@ +package main + +import ( + "bufio" + "fmt" + "io" + "os" + "strings" + + "github.com/github/go-spdx/v2/spdxexp" + "github.com/spf13/cobra" +) + +var filePath string + +var rootCmd = &cobra.Command{ + Use: "spdx-validate", + Short: "Validate SPDX license expressions", + Long: `spdx-validate reads newline-separated SPDX license expressions and validates them. + +It reads from stdin by default, or from a file specified with -f/--file. +Blank lines are skipped. Exits 0 if all expressions are valid, or 1 if any +are invalid. + +Examples: + echo "MIT" | spdx-validate + printf "MIT\nApache-2.0\n" | spdx-validate + spdx-validate -f licenses.txt`, + RunE: func(cmd *cobra.Command, args []string) error { + var r io.Reader = os.Stdin + if filePath != "" { + f, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("unable to open file: %w", err) + } + defer f.Close() + r = f + } + ok, err := validateExpressions(r, os.Stderr) + if err != nil { + return err + } + if !ok { + os.Exit(1) + } + return nil + }, + SilenceUsage: true, + SilenceErrors: true, +} + +func init() { + rootCmd.Flags().StringVarP(&filePath, "file", "f", "", "path to a newline-separated file of SPDX expressions") +} + +// validateExpressions reads newline-separated SPDX expressions from r, +// validates each one, and writes error messages to w for any that are invalid. +// Returns (true, nil) when all are valid, (false, nil) when any are invalid, or +// (false, err) on read errors or when no expressions are found. +func validateExpressions(r io.Reader, w io.Writer) (bool, error) { + scanner := bufio.NewScanner(r) + lineNum := 0 + failures := 0 + + for scanner.Scan() { + lineNum++ + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + valid, _ := spdxexp.ValidateLicenses([]string{line}) + if !valid { + failures++ + fmt.Fprintf(w, "line %d: invalid SPDX expression: %q\n", lineNum, line) + } + } + + if err := scanner.Err(); err != nil { + return false, fmt.Errorf("error reading file: %w", err) + } + + if lineNum == 0 || (lineNum > 0 && failures == lineNum) { + return false, fmt.Errorf("no valid expressions found") + } + + if failures > 0 { + fmt.Fprintf(w, "%d of %d expressions failed validation\n", failures, lineNum) + return false, nil + } + + return true, nil +} + +func main() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/cmd/spdx-validate/main_test.go b/cmd/spdx-validate/main_test.go new file mode 100644 index 0000000..9fe7388 --- /dev/null +++ b/cmd/spdx-validate/main_test.go @@ -0,0 +1,218 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +// --- Tests for single expression on stdin --- + +func TestValidateExpressions_SingleValid(t *testing.T) { + tests := []string{ + "MIT", + "Apache-2.0", + "BSD-3-Clause", + "Apache-2.0 OR MIT", + "MIT AND ISC", + "GPL-3.0-only WITH Classpath-exception-2.0", + } + for _, expr := range tests { + t.Run(expr, func(t *testing.T) { + r := strings.NewReader(expr + "\n") + var w bytes.Buffer + ok, err := validateExpressions(r, &w) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Errorf("expected valid, got invalid; stderr: %s", w.String()) + } + if w.Len() != 0 { + t.Errorf("expected no stderr output, got: %s", w.String()) + } + }) + } +} + +func TestValidateExpressions_SingleInvalid(t *testing.T) { + tests := []string{ + "BOGUS-LICENSE", + "NOT-A-REAL-ID", + "MIT ANDOR Apache-2.0", + } + for _, expr := range tests { + t.Run(expr, func(t *testing.T) { + r := strings.NewReader(expr + "\n") + var w bytes.Buffer + ok, err := validateExpressions(r, &w) + if err == nil { + t.Fatal("expected error for single invalid expression, got nil") + } + if ok { + t.Error("expected invalid, got valid") + } + if !strings.Contains(w.String(), "invalid SPDX expression") { + t.Errorf("expected error message in output, got: %s", w.String()) + } + if !strings.Contains(w.String(), expr) { + t.Errorf("expected expression %q in output, got: %s", expr, w.String()) + } + }) + } +} + +// --- Tests for multiple expressions --- + +func TestValidateExpressions_AllValid(t *testing.T) { + input := "MIT\nApache-2.0\nBSD-3-Clause OR MIT\n" + r := strings.NewReader(input) + var w bytes.Buffer + ok, err := validateExpressions(r, &w) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Errorf("expected all valid, got invalid; stderr: %s", w.String()) + } + if w.Len() != 0 { + t.Errorf("expected no stderr output, got: %s", w.String()) + } +} + +func TestValidateExpressions_SomeInvalid(t *testing.T) { + input := "MIT\nNOT-A-LICENSE\nApache-2.0\nALSO-BOGUS\n" + r := strings.NewReader(input) + var w bytes.Buffer + ok, err := validateExpressions(r, &w) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok { + t.Error("expected invalid result, got valid") + } + output := w.String() + if !strings.Contains(output, `"NOT-A-LICENSE"`) { + t.Errorf("expected NOT-A-LICENSE in output, got: %s", output) + } + if !strings.Contains(output, `"ALSO-BOGUS"`) { + t.Errorf("expected ALSO-BOGUS in output, got: %s", output) + } + if !strings.Contains(output, "line 2:") { + t.Errorf("expected 'line 2:' in output, got: %s", output) + } + if !strings.Contains(output, "line 4:") { + t.Errorf("expected 'line 4:' in output, got: %s", output) + } + if !strings.Contains(output, "2 of 4 expressions failed") { + t.Errorf("expected summary in output, got: %s", output) + } +} + +func TestValidateExpressions_AllInvalid(t *testing.T) { + input := "BOGUS-1\nBOGUS-2\n" + r := strings.NewReader(input) + var w bytes.Buffer + ok, err := validateExpressions(r, &w) + if err == nil { + t.Fatal("expected error when all expressions are invalid, got nil") + } + if ok { + t.Error("expected ok=false") + } + if !strings.Contains(err.Error(), "no valid expressions found") { + t.Errorf("expected 'no valid expressions found' error, got: %v", err) + } +} + +func TestValidateExpressions_EmptyFile(t *testing.T) { + r := strings.NewReader("") + var w bytes.Buffer + ok, err := validateExpressions(r, &w) + if err == nil { + t.Fatal("expected error for empty file, got nil") + } + if ok { + t.Error("expected ok=false for empty file") + } + if !strings.Contains(err.Error(), "no valid expressions found") { + t.Errorf("expected 'no valid expressions found' error, got: %v", err) + } +} + +func TestValidateExpressions_SkipsBlankLines(t *testing.T) { + input := "\nMIT\n\n\nApache-2.0\n\n" + r := strings.NewReader(input) + var w bytes.Buffer + ok, err := validateExpressions(r, &w) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Errorf("expected all valid, got invalid; stderr: %s", w.String()) + } +} + +// --- Integration test using a temp file --- + +func TestValidateExpressions_FromTempFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "licenses.txt") + + content := "MIT\nApache-2.0\nBSD-2-Clause\n" + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatalf("failed to write temp file: %v", err) + } + + f, err := os.Open(path) + if err != nil { + t.Fatalf("failed to open temp file: %v", err) + } + defer f.Close() + + var w bytes.Buffer + ok, err := validateExpressions(f, &w) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !ok { + t.Errorf("expected all valid from file, got invalid; stderr: %s", w.String()) + } +} + +func TestValidateExpressions_FromTempFileWithFailures(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "licenses.txt") + + content := "MIT\nINVALID-1\nApache-2.0\nINVALID-2\nBSD-2-Clause\n" + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatalf("failed to write temp file: %v", err) + } + + f, err := os.Open(path) + if err != nil { + t.Fatalf("failed to open temp file: %v", err) + } + defer f.Close() + + var w bytes.Buffer + ok, err := validateExpressions(f, &w) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ok { + t.Error("expected invalid result from file with bad entries") + } + output := w.String() + if !strings.Contains(output, `"INVALID-1"`) { + t.Errorf("expected INVALID-1 in output, got: %s", output) + } + if !strings.Contains(output, `"INVALID-2"`) { + t.Errorf("expected INVALID-2 in output, got: %s", output) + } + if !strings.Contains(output, "2 of 5 expressions failed") { + t.Errorf("expected summary in output, got: %s", output) + } +} diff --git a/go.mod b/go.mod index 408aa69..620ed3b 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,15 @@ retract v2.3.0 // Compatibility issues with go 1.22 go 1.24 -require github.com/stretchr/testify v1.8.1 +require ( + github.com/spf13/cobra v1.10.2 + github.com/stretchr/testify v1.8.1 +) require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 2ec90f7..b0af8ab 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,16 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -10,6 +18,7 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=