blob: 64e913e8d5ca14da2e042fdf4887e19262941266 [file]
// Copyright 2026 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 task
import (
"context"
"path"
"reflect"
"strings"
"testing"
"golang.org/x/build/gerrit"
wf "golang.org/x/build/internal/workflow"
"golang.org/x/build/relmeta"
"golang.org/x/vulndb/report"
yaml "gopkg.in/yaml.v3"
)
func TestStartsWithASCII(t *testing.T) {
tests := []struct {
in string
want bool
}{
{"hello", true},
{"Hello", true},
{"zoo", true},
{"Zoo", true},
{"aBC", true},
{"ZBC", true},
{"1abc", false},
{" abc", false},
{"", false},
{"日本語", false},
{"{bad", false},
{"@at", false},
{"[bracket", false},
}
for _, tt := range tests {
if got := startsWithASCII(tt.in); got != tt.want {
t.Errorf("startsWithAscii(%q) = %v, want %v", tt.in, got, tt.want)
}
}
}
func TestSubject(t *testing.T) {
reports := []*report.Report{
{ID: "GO-2026-0001"},
{ID: "GO-2026-0002"},
}
got := Subject(reports)
if !strings.Contains(got, "add 2 first party reports") {
t.Errorf("subject missing report count:\n%s", got)
}
if !strings.Contains(got, "Fixes golang/vulndb#0001") {
t.Errorf("subject missing first fix line:\n%s", got)
}
if !strings.Contains(got, "Fixes golang/vulndb#0002") {
t.Errorf("subject missing second fix line:\n%s", got)
}
}
func TestStdVulnReportVersions(t *testing.T) {
tests := []struct {
name string
targets []string
want report.Versions
wantErr bool
}{
{
name: "single",
targets: []string{"go1.1.0"},
want: report.Versions{report.Fixed("1.1.0")},
},
{
name: "two versions",
targets: []string{"go1.24.1", "go1.23.5"},
want: report.Versions{
report.Fixed("1.23.5"),
report.Introduced("1.24.0-0"),
report.Fixed("1.24.1"),
},
},
{
name: "empty",
targets: nil,
wantErr: true,
},
{
name: "invalid semver",
targets: []string{"not-a-version"},
wantErr: true,
},
{
name: "bare semver rejected",
targets: []string{"1.24.1"},
wantErr: true,
},
{
name: "two component rejected",
targets: []string{"go1.26"},
wantErr: true,
},
{
name: "one component rejected",
targets: []string{"go1"},
wantErr: true,
},
{
name: "non-numeric minor rejected",
targets: []string{"go1.2a.3"},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := stdVulnReportVersions(tt.targets)
if (err != nil) != tt.wantErr {
t.Fatalf("stdVulnReportVersions(%v): err = %v, wantErr = %v", tt.targets, err, tt.wantErr)
}
if err != nil {
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("stdVulnReportVersions(%v):\ngot %v\nwant %v", tt.targets, got, tt.want)
}
})
}
}
func TestVulnReport(t *testing.T) {
mod := VulnModuleInfo{Module: "golang.org/x/net", Versions: report.Versions{report.Fixed("1.1.0")}, VulnerableAt: report.VulnerableAt("1.0.0")}
const announceURL = "https://groups.google.com/g/golang-announce/c/test"
t.Run("valid", func(t *testing.T) {
p := &relmeta.SecurityPatch{
Package: "golang.org/x/net/http2",
GitHubIssueID: 12345,
Changelists: []string{"https://go.dev/cl/111"},
ReleaseNote: "net/http2: bad things happen.\n\nDetails about the bad things.",
CVE: "CVE-2026-0001",
CWE: "CWE-400",
Credits: []string{"Alice"},
VulnReportID: "GO-2026-0001",
}
r, err := VulnReport(p, mod, announceURL)
if err != nil {
t.Fatal(err)
}
if r.ID != "GO-2026-0001" {
t.Errorf("ID = %q, want GO-2026-0001", r.ID)
}
if got := string(r.Summary); got != "Bad things happen in net/http2" {
t.Errorf("Summary = %q", got)
}
if got := string(r.Description); got != "Details about the bad things." {
t.Errorf("Description = %q", got)
}
if r.CVEMetadata.ID != "CVE-2026-0001" {
t.Errorf("CVE = %q", r.CVEMetadata.ID)
}
if len(r.Modules) != 1 || r.Modules[0].Module != "golang.org/x/net" {
t.Errorf("Module = %v", r.Modules)
}
if got := r.Modules[0].VulnerableAt; got == nil || got.Version != "1.0.0" {
t.Errorf("VulnerableAt = %v, want 1.0.0", got)
}
if r.Modules[0].Packages[0].Package != "golang.org/x/net/http2" {
t.Errorf("Package = %q", r.Modules[0].Packages[0].Package)
}
if r.ReviewStatus != report.Reviewed {
t.Errorf("ReviewStatus = %v", r.ReviewStatus)
}
})
t.Run("dotted identifier preserves interior periods", func(t *testing.T) {
p := &relmeta.SecurityPatch{
Package: "net/http",
GitHubIssueID: 12345,
Changelists: []string{"https://go.dev/cl/111"},
ReleaseNote: "net/http: TLS 1.3 handshake panics.\n\nDetails about the panic.",
CVE: "CVE-2026-0002",
CWE: "CWE-400",
Credits: []string{"Bob"},
VulnReportID: "GO-2026-0002",
}
stdMod := VulnModuleInfo{Module: "std", Versions: report.Versions{report.Fixed("1.1.0")}, VulnerableAt: report.VulnerableAt("1.0.0")}
r, err := VulnReport(p, stdMod, announceURL)
if err != nil {
t.Fatal(err)
}
if got := string(r.Summary); got != "TLS 1.3 handshake panics in net/http" {
t.Errorf("Summary = %q, want %q", got, "TLS 1.3 handshake panics in net/http")
}
})
t.Run("VulnReportDesc override", func(t *testing.T) {
p := &relmeta.SecurityPatch{
Package: "golang.org/x/net/http2",
GitHubIssueID: 12345,
Changelists: []string{"https://go.dev/cl/111"},
ReleaseNote: "net/http2: bad things happen.\n\nOriginal description.",
VulnReportDesc: "Overridden description.",
VulnReportID: "GO-2026-0001",
CVE: "CVE-2026-0001",
}
r, err := VulnReport(p, mod, announceURL)
if err != nil {
t.Fatal(err)
}
if got := string(r.Description); got != "Overridden description." {
t.Errorf("Description = %q, want overridden", got)
}
})
t.Run("missing versions", func(t *testing.T) {
p := &relmeta.SecurityPatch{
Package: "golang.org/x/net/http2",
GitHubIssueID: 12345,
Changelists: []string{"https://go.dev/cl/111"},
ReleaseNote: "net/http2: bad.\n\nDetails.",
}
if _, err := VulnReport(p, VulnModuleInfo{Module: "golang.org/x/net"}, announceURL); err == nil {
t.Fatal("expected error for missing versions")
}
})
t.Run("missing github issue", func(t *testing.T) {
p := &relmeta.SecurityPatch{
Package: "golang.org/x/net/http2",
Changelists: []string{"https://go.dev/cl/111"},
ReleaseNote: "net/http2: bad.\n\nDetails.",
}
if _, err := VulnReport(p, mod, announceURL); err == nil {
t.Fatal("expected error for missing github issue")
}
})
t.Run("missing changelists", func(t *testing.T) {
p := &relmeta.SecurityPatch{
Package: "golang.org/x/net/http2",
GitHubIssueID: 12345,
ReleaseNote: "net/http2: bad.\n\nDetails.",
}
if _, err := VulnReport(p, mod, announceURL); err == nil {
t.Fatal("expected error for missing changelists")
}
})
t.Run("missing announce URL", func(t *testing.T) {
p := &relmeta.SecurityPatch{
Package: "golang.org/x/net/http2",
GitHubIssueID: 12345,
Changelists: []string{"https://go.dev/cl/111"},
ReleaseNote: "net/http2: bad.\n\nDetails.",
}
if _, err := VulnReport(p, mod, ""); err == nil {
t.Fatal("expected error for missing announce URL")
}
})
t.Run("malformed release note no newline", func(t *testing.T) {
p := &relmeta.SecurityPatch{
Package: "golang.org/x/net/http2",
GitHubIssueID: 12345,
Changelists: []string{"https://go.dev/cl/111"},
ReleaseNote: "no newline here",
}
if _, err := VulnReport(p, mod, announceURL); err == nil {
t.Fatal("expected error for malformed release note")
}
})
t.Run("malformed release note no colon", func(t *testing.T) {
p := &relmeta.SecurityPatch{
Package: "golang.org/x/net/http2",
GitHubIssueID: 12345,
Changelists: []string{"https://go.dev/cl/111"},
ReleaseNote: "no colon in subject\n\nDetails.",
}
if _, err := VulnReport(p, mod, announceURL); err == nil {
t.Fatal("expected error for malformed subject")
}
})
t.Run("empty summary after trim", func(t *testing.T) {
p := &relmeta.SecurityPatch{
Package: "golang.org/x/net/http2",
GitHubIssueID: 12345,
Changelists: []string{"https://go.dev/cl/111"},
ReleaseNote: "net/http2: .\n\nDetails.",
}
if _, err := VulnReport(p, mod, announceURL); err == nil {
t.Fatal("expected error for empty summary after trim")
}
})
t.Run("non-ascii summary", func(t *testing.T) {
p := &relmeta.SecurityPatch{
Package: "golang.org/x/net/http2",
GitHubIssueID: 12345,
Changelists: []string{"https://go.dev/cl/111"},
ReleaseNote: "net/http2: 日本語\n\nDetails.",
}
if _, err := VulnReport(p, mod, announceURL); err == nil {
t.Fatal("expected error for non-ascii summary")
}
})
}
func TestVulnModule(t *testing.T) {
tests := []struct {
pkg string
want string
}{
{"net/http", "std"},
{"crypto/tls", "std"},
{"cmd/go", "cmd"},
{"cmd", "cmd"},
{"cmd/compile/internal/ssa", "cmd"},
{"golang.org/x/net/http2", "golang.org/x/net"},
{"golang.org/x/crypto/ssh", "golang.org/x/crypto"},
{"golang.org/x/net", "golang.org/x/net"},
{"", "std"},
}
for _, tc := range tests {
t.Run(tc.pkg, func(t *testing.T) {
if got := VulnModule(tc.pkg); got != tc.want {
t.Errorf("got %q, want %q", got, tc.want)
}
})
}
}
func TestStdVulnerableAt(t *testing.T) {
tests := []struct {
name string
targets []string
wantVer string
wantErr bool
}{
{
name: "two release lines",
targets: []string{"go1.25.10", "go1.26.3"},
wantVer: "1.26.2",
},
{
name: "single release",
targets: []string{"go1.26.3"},
wantVer: "1.26.2",
},
{
name: "result patch zero",
targets: []string{"go1.24.1", "go1.25.1"},
wantVer: "1.25.0",
},
{
name: "empty input",
targets: []string{},
wantErr: true,
},
{
name: "patch zero",
targets: []string{"go1.26.0"},
wantErr: true,
},
{
name: "invalid semver",
targets: []string{"not.a.version"},
wantErr: true,
},
{
name: "two component version",
targets: []string{"go1.26"},
wantErr: true,
},
{
name: "bare semver rejected",
targets: []string{"1.26.3"},
wantErr: true,
},
{
name: "one component rejected",
targets: []string{"go1"},
wantErr: true,
},
{
name: "non-numeric minor rejected",
targets: []string{"go1.2a.3"},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := stdVulnerableAt(tt.targets)
if (err != nil) != tt.wantErr {
t.Fatalf("got %v, want %v", err, tt.wantErr)
}
if err != nil {
return
}
if got.Version != tt.wantVer {
t.Errorf("got %q, want %q", got.Version, tt.wantVer)
}
})
}
}
func TestStdVulnerableAtPrerelease(t *testing.T) {
// A pre-release suffix like "1.26.3-rc1" passes semver.IsValid
// but makes the patch extraction fail (strconv.Atoi on "3-rc1").
_, err := stdVulnerableAt([]string{"go1.26.3-rc1"})
if err == nil {
t.Fatal("expected error for pre-release target, got nil")
}
if !strings.Contains(err.Error(), "non-numeric patch") {
t.Errorf("unexpected error: %v", err)
}
}
func TestStdVulnModuleInfo(t *testing.T) {
tests := []struct {
name string
p *relmeta.SecurityPatch
wantMod string
wantVer string
wantVers report.Versions
wantErr bool
}{
{
name: "std package",
p: &relmeta.SecurityPatch{
Package: "net/http",
TargetReleases: []string{"go1.25.10", "go1.26.3"},
},
wantMod: "std",
wantVer: "1.26.2",
wantVers: report.Versions{report.Fixed("1.25.10"), report.Introduced("1.26.0-0"), report.Fixed("1.26.3")},
},
{
name: "cmd package",
p: &relmeta.SecurityPatch{
Package: "cmd/go",
TargetReleases: []string{"go1.26.3"},
},
wantMod: "cmd",
wantVer: "1.26.2",
wantVers: report.Versions{report.Fixed("1.26.3")},
},
{
name: "invalid target releases",
p: &relmeta.SecurityPatch{
Package: "net/http",
TargetReleases: []string{"not-a-version"},
},
wantErr: true,
},
{
name: "empty target releases",
p: &relmeta.SecurityPatch{
Package: "net/http",
TargetReleases: nil,
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mod, err := StdVulnModuleInfo(tt.p)
if (err != nil) != tt.wantErr {
t.Fatalf("got %v, want %v", err, tt.wantErr)
}
if err != nil {
return
}
if mod.Module != tt.wantMod {
t.Errorf("Module = %q, want %q", mod.Module, tt.wantMod)
}
if mod.VulnerableAt == nil || mod.VulnerableAt.Version != tt.wantVer {
t.Errorf("VulnerableAt = %v, want %q", mod.VulnerableAt, tt.wantVer)
}
if !reflect.DeepEqual(mod.Versions, tt.wantVers) {
t.Errorf("Versions = %v, want %v", mod.Versions, tt.wantVers)
}
})
}
}
type fakeVulnGerrit struct {
*FakeGerrit
gotInput gerrit.ChangeInput
gotReviewers []string
gotFiles map[string]string
}
func (g *fakeVulnGerrit) CreateAutoSubmitChange(ctx *wf.TaskContext, input gerrit.ChangeInput, reviewers []string, files map[string]string) (string, error) {
g.gotInput = input
g.gotReviewers = reviewers
g.gotFiles = files
return g.FakeGerrit.CreateAutoSubmitChange(ctx, input, reviewers, files)
}
func TestMailVulnReports(t *testing.T) {
t.Run("empty reports", func(t *testing.T) {
ctx := &wf.TaskContext{Context: context.Background(), Logger: &testLogger{t: t}}
changeID, err := MailVulnReports(ctx, nil, nil, nil)
if err != nil {
t.Fatal(err)
}
if changeID != "" {
t.Errorf("got change ID %q, want empty", changeID)
}
})
t.Run("happy path", func(t *testing.T) {
vulnRepo := NewFakeRepo(t, "vulndb")
gc := &fakeVulnGerrit{
FakeGerrit: NewFakeGerrit(t, vulnRepo),
}
ctx := &wf.TaskContext{Context: context.Background(), Logger: &testLogger{t: t}}
reports := []*report.Report{
{ID: "GO-2026-0001"},
{ID: "GO-2026-0002"},
}
wantReviewers := []string{"reviewer-a@google.com", "reviewer-b@google.com"}
changeID, err := MailVulnReports(ctx, gc, reports, wantReviewers)
if err != nil {
t.Fatal(err)
}
if changeID == "" {
t.Fatal("expected non-empty change ID")
}
if gc.gotInput.Project != "vulndb" {
t.Errorf("project = %q, want vulndb", gc.gotInput.Project)
}
if gc.gotInput.Branch != "master" {
t.Errorf("branch = %q, want master", gc.gotInput.Branch)
}
if !reflect.DeepEqual(gc.gotReviewers, wantReviewers) {
t.Errorf("reviewers = %v, want %v", gc.gotReviewers, wantReviewers)
}
for _, id := range []string{"GO-2026-0001", "GO-2026-0002"} {
key := path.Join("data", "reports", id+".yaml")
content, ok := gc.gotFiles[key]
if !ok {
t.Errorf("missing file %q in submitted files", key)
continue
}
var got report.Report
if err := yaml.Unmarshal([]byte(content), &got); err != nil {
t.Errorf("unmarshal %q: %v", key, err)
continue
}
if got.ID != id {
t.Errorf("file %q: ID = %q, want %q", key, got.ID, id)
}
}
})
t.Run("open CL exists", func(t *testing.T) {
vulnRepo := NewFakeRepo(t, "vulndb")
fg := NewFakeGerrit(t, vulnRepo)
gc := &fakeVulnGerrit{FakeGerrit: fg}
reports := []*report.Report{
{ID: "GO-2026-0001"},
{ID: "GO-2026-0002"},
}
fg.AddChange("vulndb", "existing-cl", &gerrit.ChangeInfo{
ID: "existing-cl",
Status: "NEW",
Branch: "master",
}, Subject(reports))
ctx := &wf.TaskContext{Context: context.Background(), Logger: &testLogger{t: t}}
changeID, err := MailVulnReports(ctx, gc, reports, nil)
if err != nil {
t.Fatal(err)
}
if changeID != "existing-cl" {
t.Errorf("got change ID %q, want %q", changeID, "existing-cl")
}
if gc.gotFiles != nil {
t.Error("expected no CL to be created when open CL exists")
}
})
}
func TestConvertInternalChangelists(t *testing.T) {
const milestoneYAML = `id: 77770001
security_patches:
- id: 1
package: golang.org/x/net/http2
track: PRIVATE
changelists:
- https://go-internal-review.git.corp.google.com/c/net/+/1111
- https://go-internal-review.git.corp.google.com/c/net/+/2222
- id: 2
package: golang.org/x/net/html
track: PUBLIC
changelists:
- https://go.dev/cl/3333
`
milestonePath := path.Join("data", "milestones", "77770001.yaml")
newGerrit := func(t *testing.T) *FakeGerrit {
smRepo := NewFakeRepo(t, "security-metadata")
smRepo.Branch("main", smRepo.History()[0])
smRepo.CommitOnBranch("main", map[string]string{milestonePath: milestoneYAML})
return NewFakeGerrit(t, smRepo)
}
readMilestone := func(t *testing.T, ctx context.Context, gc *FakeGerrit) string {
head, err := gc.ReadBranchHead(ctx, "security-metadata", "main")
if err != nil {
t.Fatal(err)
}
b, err := gc.ReadFile(ctx, "security-metadata", head, milestonePath)
if err != nil {
t.Fatal(err)
}
return string(b)
}
external := map[string]string{
"https://go-internal-review.git.corp.google.com/c/net/+/1111": "https://go.dev/cl/558675",
"https://go-internal-review.git.corp.google.com/c/net/+/2222": "https://go.dev/cl/558676",
}
wantReviewers := []string{"reviewer-a@google.com"}
t.Run("rewrites in place and refetches", func(t *testing.T) {
ctx := &wf.TaskContext{Context: context.Background(), Logger: &testLogger{t: t}}
gc := newGerrit(t)
rm, err := ConvertInternalChangelists(ctx, gc, "77770001", external, wantReviewers)
if err != nil {
t.Fatal(err)
}
if got, want := rm.Patches[0].Changelists, []string{"https://go.dev/cl/558675", "https://go.dev/cl/558676"}; !reflect.DeepEqual(got, want) {
t.Errorf("private patch changelists = %v, want %v", got, want)
}
if got, want := rm.Patches[1].Changelists, []string{"https://go.dev/cl/3333"}; !reflect.DeepEqual(got, want) {
t.Errorf("public patch changelists = %v, want %v", got, want)
}
if !reflect.DeepEqual(gc.LastReviewers, wantReviewers) {
t.Errorf("reviewers = %v, want %v", gc.LastReviewers, wantReviewers)
}
if got := readMilestone(t, ctx, gc); strings.Contains(got, "go-internal-review") {
t.Errorf("milestone at head still has private links:\n%s", got)
}
gc.LastReviewers = nil
again, err := ConvertInternalChangelists(ctx, gc, "77770001", external, wantReviewers)
if err != nil {
t.Fatal(err)
}
if gc.LastReviewers != nil {
t.Errorf("second call mailed a change with reviewers %v", gc.LastReviewers)
}
if !reflect.DeepEqual(again, rm) {
t.Errorf("second call milestone = %+v, want %+v", again, rm)
}
})
t.Run("nothing to convert", func(t *testing.T) {
ctx := &wf.TaskContext{Context: context.Background(), Logger: &testLogger{t: t}}
gc := newGerrit(t)
before := readMilestone(t, ctx, gc)
if _, err := ConvertInternalChangelists(ctx, gc, "77770001", nil, wantReviewers); err != nil {
t.Fatal(err)
}
if gc.LastReviewers != nil {
t.Errorf("mailed a change with reviewers %v", gc.LastReviewers)
}
if after := readMilestone(t, ctx, gc); after != before {
t.Errorf("milestone changed without external changelists:\ngot %s\nwant %s", after, before)
}
})
}