diff --git a/toolkit/fixtures/fixtures.go b/toolkit/fixtures/fixtures.go new file mode 100644 index 000000000..31f58c79a --- /dev/null +++ b/toolkit/fixtures/fixtures.go @@ -0,0 +1,110 @@ +// Package fixtures provides definitions and helpers implementing the [security +// reporting fixture spec]. +// +// [security reporting fixture spec]: https://clairproject.org/TODO +package fixtures + +import ( + "bufio" + "context" + "encoding/csv" + "errors" + "fmt" + "io" + "iter" + "regexp" + "strings" +) + +// These are the known media types. +const ( + MediaTypeVEX = `application/csaf+json` + MediaTypeManifest1 = `application/vnd.com.redhat.container.acceptancetest.v1+csv` + MediaTypeBOM = `application/spdx+json` +) + +// These are the compressed variants of the known media types. +const ( + MediaTypeZstdVEX = `application/csaf+json+zstd` + MediaTypeZstdManifest1 = `application/vnd.com.redhat.container.acceptancetest.v1+csv+zstd` + MediaTypeZstdBOM = `application/spdx+json+zstd` +) + +func ParseManifest(ctx context.Context, mt string, rd io.Reader) (iter.Seq2[ManifestRecord, error], error) { + mr := csv.NewReader(bufio.NewReader(rd)) + mr.ReuseRecord = true + mr.Comment = '#' + switch mt { + case MediaTypeManifest1, MediaTypeZstdManifest1: + mr.FieldsPerRecord = 3 + default: + return nil, fmt.Errorf("fixtures: unknown media type %q", mt) + } + + seq := func(yield func(ManifestRecord, error) bool) { + for { + var m ManifestRecord + s, err := mr.Read() + switch { + case err == nil: + case errors.Is(err, io.EOF): + return + default: + err := fmt.Errorf("fixtures: error at position %d: %w", mr.InputOffset(), err) + yield(m, err) + return + } + + if !trackingID.MatchString(s[0]) { + l, c := mr.FieldPos(0) + err := fmt.Errorf("fixtures: invalid tracking ID at line %d, column %d", l, c) + yield(m, err) + return + } + m.ID = s[0] + + if len(s[1]) == 0 { + l, c := mr.FieldPos(1) + err := fmt.Errorf("fixtures: invalid product ID at line %d, column %d", l, c) + yield(m, err) + return + } + m.Product = s[1] + + switch { + case strings.EqualFold(s[2], `affected`): + m.Status = StatusAffected + case strings.EqualFold(s[2], `unaffected`): + m.Status = StatusUnaffected + default: + m.Status = StatusUnknown + } + + if !yield(m, nil) { + return + } + } + } + + return seq, nil +} + +// TrackingID is the regexp that a CSAF document's ID must conform to. +// +// See also: https://docs.oasis-open.org/csaf/csaf/v2.0/os/csaf-v2.0-os.html#321124-document-property---tracking---id +var trackingID = regexp.MustCompile(`^[\S](.*[\S])?$`) + +type ManifestRecord struct { + ID string + Product string + Status VulnerabilityStatus +} + +//go:generate go run golang.org/x/tools/cmd/stringer@latest -linecomment -type=VulnerabilityStatus +type VulnerabilityStatus uint + +const ( + StatusUnknown VulnerabilityStatus = iota // UNKNOWN + StatusAffected // AFFECTED + StatusUnaffected // UNAFFECTED +) diff --git a/toolkit/fixtures/manifest_test.go b/toolkit/fixtures/manifest_test.go new file mode 100644 index 000000000..e0cb8026e --- /dev/null +++ b/toolkit/fixtures/manifest_test.go @@ -0,0 +1,72 @@ +package fixtures + +import ( + "bytes" + "encoding/json" + "path/filepath" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "golang.org/x/tools/txtar" +) + +func TestManifest(t *testing.T) { + ms, _ := filepath.Glob("testdata/manifest/*.txtar") + for _, m := range ms { + name := strings.TrimSuffix(filepath.Base(m), ".txtar") + t.Run(name, func(t *testing.T) { + t.Parallel() + ar, err := txtar.ParseFile(m) + if err != nil { + t.Fatal(err) + } + var input, wantJSON *txtar.File + for i := range ar.Files { + f := &ar.Files[i] + switch f.Name { + case "input.csv": + input = f + case "want.json": + wantJSON = f + default: // Skip + } + } + if input == nil || wantJSON == nil { + t.Fatal(`malformed archive: missing "input.csv" or "want.json"`) + } + + ctx := t.Context() + seq, err := ParseManifest(ctx, MediaTypeManifest1, bytes.NewReader(input.Data)) + if err != nil { + t.Fatal(err) + } + wantBuf := bytes.NewBuffer(wantJSON.Data) + + lineNo := 0 + for r, err := range seq { + lineNo++ + if err != nil { + t.Fatalf("input.csv: line %d: unexpected error: %v", lineNo, err) + } + wantLine, wantErr := wantBuf.ReadBytes('\n') + if wantErr != nil && len(wantLine) == 0 { + t.Fatalf("want.json: line %d: %v", lineNo, wantErr) + } + var want []string + if err := json.Unmarshal(wantLine, &want); err != nil { + t.Fatalf("want.json: line %d: %v", lineNo, err) + } + + got := []string{r.ID, r.Product, r.Status.String()} + if !cmp.Equal(got, want) { + t.Error(cmp.Diff(got, want)) + } + } + + if l := wantBuf.Len(); l != 0 { + t.Errorf("want.json: %d unread bytes left", l) + } + }) + } +} diff --git a/toolkit/fixtures/testdata/manifest/Simple.txtar b/toolkit/fixtures/testdata/manifest/Simple.txtar new file mode 100644 index 000000000..004d47d27 --- /dev/null +++ b/toolkit/fixtures/testdata/manifest/Simple.txtar @@ -0,0 +1,5 @@ +-- input.csv -- +# Comments are ignored. +1,1, +-- want.json -- +["1","1","UNKNOWN"] diff --git a/toolkit/fixtures/vulnerabilitystatus_string.go b/toolkit/fixtures/vulnerabilitystatus_string.go new file mode 100644 index 000000000..604e9b75f --- /dev/null +++ b/toolkit/fixtures/vulnerabilitystatus_string.go @@ -0,0 +1,26 @@ +// Code generated by "stringer -linecomment -type=VulnerabilityStatus"; DO NOT EDIT. + +package fixtures + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[StatusUnknown-0] + _ = x[StatusAffected-1] + _ = x[StatusUnaffected-2] +} + +const _VulnerabilityStatus_name = "UNKNOWNAFFECTEDUNAFFECTED" + +var _VulnerabilityStatus_index = [...]uint8{0, 7, 15, 25} + +func (i VulnerabilityStatus) String() string { + idx := int(i) - 0 + if i < 0 || idx >= len(_VulnerabilityStatus_index)-1 { + return "VulnerabilityStatus(" + strconv.FormatInt(int64(i), 10) + ")" + } + return _VulnerabilityStatus_name[_VulnerabilityStatus_index[idx]:_VulnerabilityStatus_index[idx+1]] +} diff --git a/toolkit/go.mod b/toolkit/go.mod index d0b8f2ee2..7968f3b7e 100644 --- a/toolkit/go.mod +++ b/toolkit/go.mod @@ -1,5 +1,8 @@ module github.com/quay/claircore/toolkit -go 1.24 +go 1.24.0 -require github.com/google/go-cmp v0.7.0 +require ( + github.com/google/go-cmp v0.7.0 + golang.org/x/tools v0.41.0 +) diff --git a/toolkit/go.sum b/toolkit/go.sum index 40e761ae7..ed2e4de91 100644 --- a/toolkit/go.sum +++ b/toolkit/go.sum @@ -1,2 +1,4 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=