blob: 3af92bcef7e5e45ba09f605c23a6d4d9f74ff984 [file] [log] [blame]
// 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 report
import (
"errors"
"flag"
"fmt"
"os"
"path"
"path/filepath"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"golang.org/x/tools/txtar"
"golang.org/x/vulndb/internal/osv"
"golang.org/x/vulndb/internal/proxy"
"golang.org/x/vulndb/internal/test"
)
var (
realProxy = flag.Bool("proxy", false, "if true, contact the real module proxy and update expected responses")
)
var (
validStdLibReferences = []*Reference{
{Type: osv.ReferenceTypeFix, URL: "https://go.dev/cl/12345"},
{Type: osv.ReferenceTypeWeb, URL: "https://groups.google.com/g/golang-announce/c/12345"},
{Type: osv.ReferenceTypeReport, URL: "https://go.dev/issue/12345"},
}
validCVEMetadata = &CVEMeta{
ID: "CVE-0000-1111",
CWE: "CWE XXX: A CWE description",
}
noop = func(*Report) {}
)
func validReport(f func(r *Report)) Report {
r := Report{
ID: "GO-0000-0000",
Modules: []*Module{{
Module: "golang.org/x/net",
VulnerableAt: "1.2.3",
Packages: []*Package{{
Package: "golang.org/x/net/http2",
}},
}},
Description: "description",
Summary: "A summary of the issue in golang.org/x/net",
CVEs: []string{"CVE-1234-0000"},
}
f(&r)
return r
}
func validStdReport(f func(r *Report)) Report {
r := Report{
ID: "GO-0000-0000",
Modules: []*Module{{
Module: "std",
VulnerableAt: "1.2.3",
Packages: []*Package{{
Package: "net/http",
}},
}},
Description: "description",
Summary: "A summary of the problem with net/http",
References: validStdLibReferences,
}
f(&r)
return r
}
func validExcludedReport(f func(r *Report)) Report {
r := Report{
ID: "GO-0000-0000",
Excluded: "NOT_GO_CODE",
CVEs: []string{"CVE-2022-1234545"},
}
f(&r)
return r
}
func TestLint(t *testing.T) {
pc, err := proxy.NewTestClient(t, *realProxy)
if err != nil {
t.Fatal(err)
}
for _, test := range []lintTC{
{
name: "module_version_ok",
desc: "Module-version pairs that exist are OK.",
report: validReport(func(r *Report) {
r.Modules = append(r.Modules, &Module{
Module: "golang.org/x/net",
Versions: []VersionRange{
{
Introduced: "0.2.0",
},
}})
}),
pc: pc,
// No lints.
},
{
name: "module_version_invalid",
desc: "Version@module must exist.",
report: validReport(func(r *Report) {
r.Modules = append(r.Modules, &Module{
Module: "golang.org/x/net",
Versions: []VersionRange{
{
Introduced: "0.2.5", // does not exist
},
}})
}),
pc: pc,
wantNumLints: 1,
},
{
name: "module_non_canonical",
desc: "Module names must be canonical.",
report: validReport(func(r *Report) {
r.Modules = append(r.Modules, &Module{
Module: "github.com/golang/vuln",
Versions: []VersionRange{
{
Introduced: "0.1.0",
},
}})
}),
pc: pc,
wantNumLints: 1,
},
{
name: "multiple_problems",
desc: "A test for a report with multiple module-version issues at once.",
report: validReport(func(r *Report) {
r.Modules = append(r.Modules, &Module{
Module: "github.com/golang/vuln",
Versions: []VersionRange{
{
Introduced: "0.1.0",
Fixed: "0.2.5", // does not exist
},
{
Introduced: "0.2.6", // does not exist
},
}})
}),
pc: pc,
wantNumLints: 1,
},
{
name: "no_proxy_client",
desc: "A non-nil proxy client must be provided.",
report: validReport(noop),
pc: nil,
wantNumLints: 1,
},
} {
test := test
t.Run(test.name, func(t *testing.T) {
got := test.report.Lint(test.pc)
updateAndCheckGolden(t, &test, got)
})
}
}
// lintTC is a lint test case.
type lintTC struct {
name, desc string
report Report
pc *proxy.Client
wantNumLints int
}
func TestLintOffline(t *testing.T) {
for _, test := range []lintTC{
{
name: "no_ID",
desc: "All reports must have an ID.",
report: validReport(func(r *Report) {
r.ID = ""
}),
wantNumLints: 1,
},
{
name: "no_modules",
desc: "All reports (except excluded reports marked NOT_GO_CODE) must have at least one module.",
report: validReport(func(r *Report) {
r.Modules = nil
}),
wantNumLints: 1,
},
{
name: "no_module_path",
desc: "Every module must have a path.",
report: validReport(func(r *Report) {
r.Modules = append(r.Modules, &Module{
// no path
})
}),
wantNumLints: 1,
},
{
name: "no_advisory",
desc: "Reports without a description must have an advisory link.",
report: validReport(func(r *Report) {
r.Description = ""
r.References = nil
}),
wantNumLints: 1,
},
{
name: "no_description_ok",
desc: "Reports with no description are OK if they have an advisory.",
report: validReport(func(r *Report) {
r.Description = ""
r.References = []*Reference{
{Type: osv.ReferenceTypeAdvisory, URL: "https://example.com"},
}
}),
wantNumLints: 0,
},
{
name: "no_description_go_cve",
desc: "Reports with a CVE assigned by the Go CNA must have a description.",
report: validReport(func(r *Report) {
r.Description = ""
r.CVEs = nil
r.CVEMetadata = validCVEMetadata
}),
wantNumLints: 1,
},
{
name: "description_line_length",
desc: "Descriptions must not (except in special cases) contain lines longer than 80 characters.",
report: validReport(func(r *Report) {
r.Description = "This line is too long; it needs to be shortened to less than 80 characters to pass the lint check"
}),
wantNumLints: 1,
},
{
name: "description_long_word_ok",
desc: "Descriptions may contain lines longer than 80 characters if the line is a single word.",
report: validReport(func(r *Report) {
r.Description = "http://1234567890.abcdefghijklmnopqrstuvwxyz.1234567890.abcdefghijklmnopqrstuvwxyz" // 82 chars ok if single word
}),
// No lints.
},
{
name: "no_summary",
desc: "Regular (non-excluded) reports must have a summary.",
report: validReport(func(r *Report) {
r.Summary = ""
}),
wantNumLints: 1,
},
{
name: "summary_todo",
desc: "Summaries must not contain TODOs.",
report: validReport(func(r *Report) {
r.Summary = "TODO: fill this out"
}),
wantNumLints: 1,
},
{
name: "summary_too_long",
desc: "The summary must be 100 characters or less.",
report: validReport(func(r *Report) {
r.Summary = "This summary of golang.org/x/net is too long; it needs to be shortened to less than 101 characters to pass the lint check"
}),
wantNumLints: 1,
},
{
name: "summary_period",
desc: "The summary should not end in a period. It should be a phrase, not a sentence.",
report: validReport(func(r *Report) {
r.Summary = "This summary of golang.org/x/net is a sentence, not a phrase."
}),
wantNumLints: 1,
},
{
name: "summary_no_path",
desc: "The summary must contain a module or package path listed in the report.",
report: validReport(func(r *Report) {
r.Summary = "This summary doesn't have a path"
}),
wantNumLints: 1,
},
{
name: "summary_path_prefix",
desc: "Summary may contain a prefix of a module or package path that is mentioned at least twice in a report. This is a workaround for reports that affect many modules.",
report: validReport(func(r *Report) {
r.Modules = append(r.Modules, &Module{
Module: "example.com/module/example/v2",
VulnerableAt: "1.0.0",
})
r.Modules = append(r.Modules, &Module{
Module: "example.com/module/example/v3",
VulnerableAt: "1.0.0",
})
r.Summary = "This summary is about example.com/module/example"
}),
// No lints.
},
{
name: "no_package_path",
desc: "All packages must have a path.",
report: validReport(func(r *Report) {
r.Modules[0].Packages = append(r.Modules[0].Packages,
&Package{
// No package path.
})
}),
wantNumLints: 1,
},
{
name: "no_vulnerable_at_or_skip_fix",
desc: "At least one of module.vulnerable_at and module.package.skip_fix must be set.",
report: validReport(func(r *Report) {
r.Modules[0].VulnerableAt = ""
r.Modules[0].Packages[0].SkipFix = ""
}),
wantNumLints: 1,
},
{
name: "skip_fix_ok",
desc: "The vulnerable_at field can be blank if skip_fix is set.",
report: validReport(func(r *Report) {
r.Modules[0].VulnerableAt = ""
r.Modules[0].Packages[0].SkipFix = "a reason"
}),
// No lints.
},
{
name: "vulnerable_at_and_skip_fix_ok",
desc: "It is OK to set both module.vulnerable_at and module.package.skip_fix.",
report: validReport(func(r *Report) {
r.Modules[0].VulnerableAt = "1.2.3"
r.Modules[0].Packages[0].SkipFix = "a reason"
}),
// No lints.
},
{
name: "vulnerable_at_out_of_range",
desc: "Field module.vulnerable_at must be inside the vulnerable version range for the module.",
report: validStdReport(func(r *Report) {
r.Modules[0].VulnerableAt = "2.0.0"
r.Modules[0].Versions = []VersionRange{
{Fixed: "1.2.1"},
}
}),
wantNumLints: 1,
},
{
name: "unsupported_versions",
desc: "The unsupported_versions field should never be set.",
report: validStdReport(func(r *Report) {
r.Modules[0].UnsupportedVersions = []UnsupportedVersion{
{Version: "1.2.1", Type: "unknown"},
}
}),
wantNumLints: 1,
},
{
name: "module_package_prefix",
desc: "In third party reports, module names must be prefixes of package names.",
report: validReport(func(r *Report) {
r.Modules = append(r.Modules, &Module{
Module: "example.com/module",
VulnerableAt: "1.0.0",
Packages: []*Package{{
Package: "example.com/package",
}},
})
}),
wantNumLints: 1,
},
{
name: "invalid_package_path",
desc: "In third party reports, package paths must pass validity checks in x/mod/module.CheckImportPath.",
report: validReport(func(r *Report) {
r.Modules = append(r.Modules, &Module{
Module: "invalid.",
VulnerableAt: "1.0.0",
Packages: []*Package{{
Package: "invalid.",
}}})
}),
wantNumLints: 1,
},
{
name: "no_package_path_stdlib",
desc: "All packages must have a path.",
report: validStdReport(func(r *Report) {
r.Modules[0].Packages[0].Package = ""
}),
wantNumLints: 1,
},
{
name: "no_package_stdlib",
desc: "In standard library reports, all modules must contain at least one package.",
report: validStdReport(func(r *Report) {
r.Modules = append(r.Modules,
&Module{
Module: "std",
VulnerableAt: "1.0.0",
// No packages.
},
)
}),
wantNumLints: 1,
},
{
name: "wrong_module_cmd",
desc: "Packages beginning with 'cmd' should be in the 'cmd' module.",
report: validStdReport(func(r *Report) {
r.Modules = append(r.Modules, &Module{
Module: "std",
VulnerableAt: "1.0.0",
Packages: []*Package{{
Package: "cmd/go",
}},
})
}),
wantNumLints: 1,
},
{
name: "versions_overlapping_ranges",
desc: "Version ranges must not overlap.",
report: validStdReport(func(r *Report) {
r.Modules[0].Versions = []VersionRange{
// Two fixed versions in a row with no introduced.
{Fixed: "1.2.1"}, {Fixed: "1.3.2"},
}
}),
wantNumLints: 1,
},
{
name: "versions_fixed_before_introduced",
desc: "Within a version range, the fixed version must come before the introduced version.",
report: validStdReport(func(r *Report) {
r.Modules[0].Versions = []VersionRange{
{
Introduced: "1.3.0",
Fixed: "1.2.1",
},
}
}),
wantNumLints: 1,
},
{
name: "versions_checked_no_vulnerable_at",
desc: "Version checks still apply if vulnerable_at is not set.",
report: validStdReport(func(r *Report) {
r.Modules[0].VulnerableAt = ""
r.Modules[0].Versions = []VersionRange{
// Two fixed versions in a row with no introduced.
{Fixed: "1.2.1"}, {Fixed: "1.3.2"},
}
}),
wantNumLints: 2,
},
{
name: "invalid_semver",
desc: "All versions must be valid, unprefixed, semver",
report: validStdReport(func(r *Report) {
r.Modules[0].Versions = []VersionRange{
{
Introduced: "1.3.X",
},
}
}),
wantNumLints: 1,
},
{
name: "bad_cve",
desc: "All CVEs must be valid.",
report: validReport(func(r *Report) {
r.CVEs = []string{"CVE.1234.5678"}
}),
wantNumLints: 1,
},
{
name: "bad_ghsa",
desc: "All GHSAs must be valid.",
report: validReport(func(r *Report) {
r.GHSAs = []string{"GHSA-123"}
}),
wantNumLints: 1,
},
{
name: "cve_and_cve_metadata_ok",
desc: "It is OK to set both cves and cve_metadata.",
report: validReport(func(r *Report) {
r.CVEs = []string{"CVE-0000-1111"}
r.CVEMetadata = validCVEMetadata
}),
// No lints.
},
{
name: "cve_metadata_missing_fields",
desc: "Field cve_metadata (if not nil), must have an ID and CWE.",
report: validReport(func(r *Report) {
r.CVEs = nil
r.CVEMetadata = &CVEMeta{
// missing fields
}
}),
wantNumLints: 2,
},
{
name: "cve_metadata_bad_fields",
desc: "Field cve_metadata must contain valid entries for ID and CWE.",
report: validReport(func(r *Report) {
r.CVEs = nil
r.CVEMetadata = &CVEMeta{
ID: "CVE.0000.1111",
CWE: "TODO",
}
}),
wantNumLints: 2,
},
{
name: "reference_invalid_type",
desc: "Reference type must be one of the pre-defined types in osv.ReferenceTypes.",
report: validReport(func(r *Report) {
r.References = append(r.References, &Reference{
Type: "INVALID",
URL: "http://go.dev/",
})
}),
wantNumLints: 1,
},
{
name: "references_multiple_advisories",
desc: "Each report should contain at most one advisory reference.",
report: validReport(func(r *Report) {
r.References = append(r.References, &Reference{
Type: "ADVISORY",
URL: "http://go.dev/a",
}, &Reference{
Type: "ADVISORY",
URL: "http://go.dev/b",
})
}),
wantNumLints: 1,
},
{
name: "references_redundant_web_advisories",
desc: "Reports should not contain redundant web-type references linking to CVEs/GHSAs listed in the cves/ghsas sections.",
report: validReport(func(r *Report) {
r.CVEs = []string{"CVE-0000-0000", "CVE-0000-0001"}
r.GHSAs = []string{"GHSA-0000-0000-0000"}
r.References = append(r.References, &Reference{
Type: "WEB",
URL: "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-0000-0000",
}, &Reference{
Type: "WEB",
URL: "https://nvd.nist.gov/vuln/detail/CVE-0000-0001",
}, &Reference{
Type: "WEB",
URL: "https://nvd.nist.gov/vuln/detail/CVE-0000-0002", // ok
}, &Reference{
Type: "WEB",
URL: "https://github.com/advisories/GHSA-0000-0000-0000",
}, &Reference{
Type: "WEB",
URL: "https://github.com/advisories/GHSA-0000-0000-0001", // ok
})
}),
wantNumLints: 3,
},
{
name: "references_unfixed",
desc: "References should not contain non-canonical link formats (that can be auto-fixed).",
report: validReport(func(r *Report) {
r.References = []*Reference{
{Type: osv.ReferenceTypeFix, URL: "https://github.com/golang/go/commit/12345"},
{Type: osv.ReferenceTypeReport, URL: "https://github.com/golang/go/issues/12345"},
{Type: osv.ReferenceTypeWeb, URL: "https://golang.org/xxx"},
{Type: osv.ReferenceTypeWeb, URL: "https://groups.google.com/forum/#!/golang-announce/12345/1/"},
}
}),
wantNumLints: 4,
},
{
name: "references_incorrect_stdlib",
desc: "Standard library reports must contain references matching a specific format.",
report: validStdReport(func(r *Report) {
r.References = []*Reference{
{Type: osv.ReferenceTypeAdvisory, URL: "http://www.example.com"},
{Type: osv.ReferenceTypeFix, URL: "https://go-review.googlesource.com/c/go/+/12345"},
{Type: osv.ReferenceTypeFix, URL: "https://github.com/golang/go/commit/12345"},
{Type: osv.ReferenceTypeReport, URL: "https://github.com/golang/go/issues/12345"},
{Type: osv.ReferenceTypeWeb, URL: "https://go.dev/"},
// no announce link
}
}),
wantNumLints: 8,
},
{
name: "references_missing_stdlib",
desc: "Standard library reports must contain at least one report, fix, and announcement link.",
report: validStdReport(func(r *Report) {
r.References = []*Reference{
// no links
}
}),
wantNumLints: 3,
},
{
name: "reference_invalid_URL",
desc: "References must be valid URLs.",
report: validReport(func(r *Report) {
r.References = []*Reference{
{
Type: osv.ReferenceTypeFix,
URL: "go.dev/cl/12345", // needs "https://" prefix
},
}
}),
wantNumLints: 1,
},
{
name: "missing_fields_excluded",
desc: "Excluded reports must contain (at least): a valid excluded reason, a module, and one CVE or GHSA.",
report: validExcludedReport(func(r *Report) {
r.Excluded = "not a real reason"
r.Modules = nil
r.CVEs = nil
r.GHSAs = nil
}),
wantNumLints: 3,
},
{
name: "bad_related",
desc: "The related field must not contain duplicate or invalid IDs.",
report: validReport(func(r *Report) {
r.CVEs = []string{"CVE-0000-1111"}
r.Related = []string{
"not-an-id", // bad
"CVE-0000-1111", // bad (duplicate)
"CVE-0000-1112", // ok
"GHSA-0000-0000-0000", // ok
"GO-1990-0001", // ok
}
}),
wantNumLints: 2,
},
{
name: "module_version_offline",
desc: "In offline mode, module-version consistency is not checked because it requires a call to the module proxy.",
report: validReport(func(r *Report) {
r.Modules = append(r.Modules, &Module{
Module: "golang.org/x/net",
Versions: []VersionRange{
{
Introduced: "0.2.5", // does not exist
},
}})
}),
// No lints: in offline mode, versions aren't checked.
},
{
name: "valid_excluded",
desc: "No lints are generated for valid excluded reports.",
report: validExcludedReport(noop),
// No lints.
},
{
name: "markdown",
desc: "Descriptions and summaries should not contain Markdown formatting.",
report: validReport(
func(r *Report) {
r.Summary += " in function `Hello`"
r.Description = "# Problem\nMore info [here](https://example.com)"
},
),
wantNumLints: 3,
},
} {
test := test
t.Run(test.name, func(t *testing.T) {
got := test.report.LintOffline()
updateAndCheckGolden(t, &test, got)
})
}
}
// The name of the "file" in the txtar archive containing the expected output.
const golden = "golden"
func updateAndCheckGolden(t *testing.T, test *lintTC, lints []string) {
if *updateGolden {
if err := updateGoldenFile(t, test, lints); err != nil {
t.Error(err)
}
}
checkGoldenFile(t, test, lints)
}
func updateGoldenFile(t *testing.T, tc *lintTC, lints []string) error {
t.Helper()
fpath := goldenFilename(t)
// Double-check that we got the right number of lints, to make it
// harder to lose/gain a lint with the auto-update.
if tc.wantNumLints != len(lints) {
return fmt.Errorf("%s: cannot update: got %d lints, want %d", fpath, len(lints), tc.wantNumLints)
}
rb, err := reportToBytes(&tc.report)
if err != nil {
return err
}
return test.WriteTxtar(fpath, []txtar.File{
{
Name: testYAMLFilename(&tc.report),
Data: rb,
},
{
Name: golden,
Data: lintsToBytes(lints),
},
}, newComment(t, tc))
}
func checkGoldenFile(t *testing.T, tc *lintTC, lints []string) {
t.Helper()
fpath := goldenFilename(t)
if _, err := os.Stat(fpath); err != nil {
t.Errorf("golden file %s does not exist (re-run test with -update flag)", fpath)
return
}
ar, err := txtar.ParseFile(fpath)
if err != nil {
t.Error(err)
return
}
wantComment, gotComment := newComment(t, tc), string(ar.Comment)
if err := test.CheckComment(wantComment, gotComment); err != nil {
t.Error(err)
}
// Check that all expected files are present and have the correct contents.
reportFile := testYAMLFilename(&tc.report)
foundReport, foundGolden := false, false
for _, f := range ar.Files {
switch af := f.Name; {
case af == golden:
want := f.Data
got := lintsToBytes(lints)
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("%s: %s: mismatch (-want, +got):\n%s", fpath, af, diff)
}
foundGolden = true
case af == reportFile:
want := f.Data
got, err := reportToBytes(&tc.report)
if err != nil {
t.Errorf("%s: %s", af, err)
continue
}
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("%s: %s: mismatch (-want, +got):\n%s", fpath, af, diff)
}
foundReport = true
default:
t.Errorf("%s: unexpected archive file %s, expected one of (%q, %q)", fpath, af, reportFile, golden)
}
}
if !foundReport {
t.Errorf("%s: no report found (want archive file %q)", fpath, reportFile)
}
if !foundGolden {
t.Errorf("%s: no golden found (want archive file %q)", fpath, golden)
}
}
func testYAMLFilename(r *Report) string {
id := r.ID
if id == "" {
id = "NO-GO-ID"
}
// Use path instead of filepath so that the paths
// always use forward slashes.
// These filenames are only used inside .txtar archives.
return path.Join(dataFolder, r.folder(), id+".yaml")
}
func newComment(t *testing.T, tc *lintTC) string {
t.Helper()
return fmt.Sprintf("Test: %s\nDescription: %s", t.Name(), tc.desc)
}
func goldenFilename(t *testing.T) string {
t.Helper()
return filepath.Join("testdata", "lint", t.Name()+".txtar")
}
func lintsToBytes(lints []string) []byte {
return []byte(strings.Join(lints, "\n") + "\n")
}
func reportToBytes(report *Report) ([]byte, error) {
ys, err := report.ToString()
if err != nil {
return nil, err
}
return []byte(ys + "\n"), nil
}
func TestCheckFilename(t *testing.T) {
for _, test := range []struct {
desc string
filename string
report Report
wantErr error
}{
{
desc: "wrong ID",
filename: "data/reports/GO-0000-0000.yaml",
report: validReport(
func(r *Report) {
r.ID = "GO-0000-1111"
}),
wantErr: errWrongID,
},
{
desc: "excluded in correct directory",
filename: "data/excluded/GO-0000-0000.yaml",
report: validExcludedReport(noop),
wantErr: nil,
},
{
desc: "excluded in wrong directory",
filename: "data/wrong/GO-0000-0000.yaml",
report: validExcludedReport(noop),
wantErr: errWrongDir,
},
{
desc: "non-excluded in correct directory",
filename: "data/reports/GO-0000-0000.yaml",
report: validReport(noop),
wantErr: nil,
},
{
desc: "non-excluded in wrong directory",
filename: "data/wrong/GO-0000-0000.yaml",
report: validReport(noop),
wantErr: errWrongDir,
},
} {
test := test
t.Run(test.desc, func(t *testing.T) {
if err := test.report.CheckFilename(test.filename); !errors.Is(err, test.wantErr) {
t.Errorf("CheckFilename(%s) = %v, want error %v", test.filename, err, test.wantErr)
}
})
}
}
func TestLintAsNotes(t *testing.T) {
// A report with lints.
report := validReport(
func(r *Report) {
r.Summary = ""
r.Notes = []*Note{
{Body: "an existing lint that will be deleted", Type: NoteTypeLint},
{Body: "a note added by a human", Type: NoteTypeNone}}
},
)
found := report.LintAsNotes(nil)
if !found {
t.Error("LintAsNotes() = false, want true")
}
want, got := []*Note{
{Body: "a note added by a human", Type: NoteTypeNone}, // preserved
{Body: "summary: missing", Type: NoteTypeLint},
{Body: "proxy client is nil; cannot perform all lint checks", Type: NoteTypeLint}}, report.Notes
if diff := cmp.Diff(want, got,
// ignore order
cmpopts.SortSlices(
func(a, b *Note) bool {
if a.Type < b.Type {
return true
} else if a.Type > b.Type {
return false
}
return a.Body < b.Body
})); diff != "" {
t.Errorf("mismatch (-want, +got):\n%s", diff)
}
}