cmd/screentest: reject negative retrypixels values

Both the global -retrypixels flag and the per-test retrypixels directive
accepted negative values. The comparison later converted the configured
int to uint64, so -1 became math.MaxUint64 and made every same-sized
difference retryable.

Reject negative values at both input paths, document the constraint, and
add regression tests.

Fixes golang/go#80898

Change-Id: I876acc9a3aec69ff07d34105fe8d703ed30b2da0
Reviewed-on: https://go-review.googlesource.com/c/website/+/816040
Auto-Submit: Jonathan Amsterdam <jba@google.com>
Reviewed-by: Dmitri Shuralyov <dmitshur@google.com>
LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Dmitri Shuralyov <dmitshur@golang.org>
Reviewed-by: David Chase <drchase@google.com>
diff --git a/cmd/screentest/main.go b/cmd/screentest/main.go
index 158ab58..0277f4d 100644
--- a/cmd/screentest/main.go
+++ b/cmd/screentest/main.go
@@ -30,7 +30,7 @@
 	-retrypixels N
 	  If the difference is no more than N pixels, take another screenshot. Repeat up to 3
 	  times. N should be small. This is a last-resort method for handling small, inexplicable
-	  output variations.
+	  output variations. N must be non-negative.
 	-run REGEXP
 	  Run only tests matching regexp.
 	-o
@@ -114,6 +114,7 @@
 	status 404
 
 Use retrypixels N to override the value of the -retrypixels flag for this test.
+N must be non-negative.
 
 	retrypixels 80
 
@@ -184,7 +185,7 @@
 	flag.StringVar(&flags.outputDirURL, "o", "", "path for output: file path or URL with 'file' or 'gs' scheme")
 	flag.StringVar(&flags.headers, "headers", "", "HTTP headers: comma-separated list of name:value")
 	flag.StringVar(&flags.filterRegexp, "run", "", "regexp to match test")
-	flag.IntVar(&flags.retryPixels, "retrypixels", 0, "repeat up to 3 times if diff is <= this value")
+	flag.IntVar(&flags.retryPixels, "retrypixels", 0, "repeat up to 3 times if diff is <= this non-negative value")
 }
 
 // options are the options for the program.
diff --git a/cmd/screentest/screentest.go b/cmd/screentest/screentest.go
index ada5ccf..f2dcd4d 100644
--- a/cmd/screentest/screentest.go
+++ b/cmd/screentest/screentest.go
@@ -254,6 +254,10 @@
 
 // commonValues returns values common to all test files.
 func commonValues(ctx context.Context, testURL, wantURL string, opts options) (c common, err error) {
+	if opts.retryPixels < 0 {
+		return common{}, fmt.Errorf("-retrypixels must be non-negative, got %d", opts.retryPixels)
+	}
+
 	// The test/want image readers/writers are relative to the test/want URLs, so
 	// they are common to all files. See test/wantPath for the file- and test-relative components.
 	// They may be nil if a URL has an http or https scheme.
@@ -396,10 +400,14 @@
 			if test == nil {
 				return nil, errors.New("directive must be in a test")
 			}
-			test.retryPixels, err = strconv.Atoi(args)
+			retryPixels, err := strconv.Atoi(args)
 			if err != nil {
 				return nil, fmt.Errorf("strconv.Atoi(%q): %w", args, err)
 			}
+			if retryPixels < 0 {
+				return nil, fmt.Errorf("RETRYPIXELS must be non-negative, got %d", retryPixels)
+			}
+			test.retryPixels = retryPixels
 
 		case "PATH":
 			if test == nil {
diff --git a/cmd/screentest/screentest_test.go b/cmd/screentest/screentest_test.go
index 000fa14..a389a26 100644
--- a/cmd/screentest/screentest_test.go
+++ b/cmd/screentest/screentest_test.go
@@ -17,6 +17,7 @@
 	"path"
 	"path/filepath"
 	"slices"
+	"strings"
 	"testing"
 	"time"
 
@@ -38,6 +39,7 @@
 		opts             options
 		want             []*testcase
 		wantErr          bool
+		wantErrText      string
 	}{
 		{
 			name:    "readtests",
@@ -212,6 +214,11 @@
 			wantErr: true,
 		},
 		{
+			name:        "readtests-negative-retrypixels",
+			wantErr:     true,
+			wantErrText: "RETRYPIXELS must be non-negative, got -1",
+		},
+		{
 			name: "readtests-filter",
 			opts: options{
 				filterRegexp: `foo \d`,
@@ -248,6 +255,9 @@
 			if (err != nil) != tt.wantErr {
 				t.Fatalf("readTests() error = %v, wantErr %v", err, tt.wantErr)
 			}
+			if err != nil && tt.wantErrText != "" && !strings.Contains(err.Error(), tt.wantErrText) {
+				t.Fatalf("readTests() error = %q, want error containing %q", err, tt.wantErrText)
+			}
 			if err != nil {
 				return
 			}
@@ -266,6 +276,17 @@
 	}
 }
 
+func TestCommonValuesRejectsNegativeRetryPixels(t *testing.T) {
+	const want = "-retrypixels must be non-negative, got -1"
+	_, err := commonValues(context.Background(), "", "", options{retryPixels: -1})
+	if err == nil {
+		t.Fatalf("commonValues() error = nil, want %q", want)
+	}
+	if got := err.Error(); got != want {
+		t.Fatalf("commonValues() error = %q, want %q", got, want)
+	}
+}
+
 func TestRun(t *testing.T) {
 	// Skip this test if Google Chrome is not installed.
 	_, err := exec.LookPath("google-chrome")
diff --git a/cmd/screentest/testdata/readtests-negative-retrypixels.txt b/cmd/screentest/testdata/readtests-negative-retrypixels.txt
new file mode 100644
index 0000000..9700827
--- /dev/null
+++ b/cmd/screentest/testdata/readtests-negative-retrypixels.txt
@@ -0,0 +1,2 @@
+test negative retrypixels
+retrypixels -1