Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
99 changes: 99 additions & 0 deletions cmd/spdx-validate/main.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
218 changes: 218 additions & 0 deletions cmd/spdx-validate/main_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
7 changes: 6 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Loading