diff --git a/internal/client/client.go b/internal/client/client.go index 15309be..22c3668 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -250,6 +250,8 @@ func (c *Client) sleep(d time.Duration) { const maxRetries = 3 +var linkNextRe = regexp.MustCompile(`<([^>]+)>;\s*rel="next"`) + // doWithRetry wraps HTTPClient.Do with retry logic for 429 and 5xx responses. // Only retries GET/DELETE/PUT on 5xx; POST/PATCH only retry on 429. func (c *Client) doWithRetry(req *http.Request) (*http.Response, error) { @@ -305,16 +307,24 @@ func resetBody(req *http.Request) { // parseRetryAfter parses the Retry-After header value as seconds. // Returns a default of 1 second if the header is missing or unparseable. func parseRetryAfter(value string) time.Duration { + const maxRetryDelay = 300 // 5 minutes; anything larger is almost certainly bogus + if value == "" { return time.Second } if seconds, err := strconv.Atoi(value); err == nil && seconds > 0 { + if seconds > maxRetryDelay { + seconds = maxRetryDelay + } return time.Duration(seconds) * time.Second } // Try HTTP-date format if t, err := http.ParseTime(value); err == nil { delay := time.Until(t) if delay > 0 { + if delay > maxRetryDelay*time.Second { + return maxRetryDelay * time.Second + } return delay } } @@ -357,9 +367,7 @@ func parseLinkNext(linkHeader string) string { if linkHeader == "" { return "" } - // Parse Link header: ; rel="next" - re := regexp.MustCompile(`<([^>]+)>;\s*rel="next"`) - matches := re.FindStringSubmatch(linkHeader) + matches := linkNextRe.FindStringSubmatch(linkHeader) if len(matches) > 1 { return matches[1] } diff --git a/internal/client/client_test.go b/internal/client/client_test.go index fb9643e..f493ea0 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -783,6 +783,10 @@ func TestParseRetryAfter(t *testing.T) { }{ {"empty", "", time.Second}, {"seconds", "5", 5 * time.Second}, + {"capped", "600", 300 * time.Second}, + {"overflow", "9227000000", 300 * time.Second}, + {"zero", "0", time.Second}, + {"negative", "-1", time.Second}, {"invalid", "not-a-number", time.Second}, } for _, tt := range tests { diff --git a/internal/client/fuzz_test.go b/internal/client/fuzz_test.go new file mode 100644 index 0000000..7bb7242 --- /dev/null +++ b/internal/client/fuzz_test.go @@ -0,0 +1,45 @@ +package client + +import "testing" + +func FuzzParseLinkNext(f *testing.F) { + f.Add("") + f.Add(`; rel="next"`) + f.Add(`; rel="prev", ; rel="next"`) + f.Add(`; rel="prev"`) + f.Add(`<>; rel="next"`) + f.Add(`malformed header`) + + f.Fuzz(func(t *testing.T, header string) { + parseLinkNext(header) // must not panic + }) +} + +func FuzzParseRetryAfter(f *testing.F) { + f.Add("") + f.Add("5") + f.Add("0") + f.Add("-1") + f.Add("999999999") + f.Add("not-a-number") + f.Add("Wed, 21 Oct 2015 07:28:00 GMT") + + f.Fuzz(func(t *testing.T, value string) { + d := parseRetryAfter(value) + if d < 0 { + t.Errorf("parseRetryAfter(%q) returned negative duration: %v", value, d) + } + }) +} + +func FuzzParsePage(f *testing.F) { + f.Add("") + f.Add("https://api.example.com/cards.json?page=2") + f.Add("https://api.example.com/cards.json") + f.Add("not-a-url") + f.Add("?page=abc") + + f.Fuzz(func(t *testing.T, nextURL string) { + ParsePage(nextURL) // must not panic + }) +} diff --git a/internal/client/testdata/fuzz/FuzzParseRetryAfter/2f261fc7602f5d07 b/internal/client/testdata/fuzz/FuzzParseRetryAfter/2f261fc7602f5d07 new file mode 100644 index 0000000..740246a --- /dev/null +++ b/internal/client/testdata/fuzz/FuzzParseRetryAfter/2f261fc7602f5d07 @@ -0,0 +1,2 @@ +go test fuzz v1 +string("9227000000") diff --git a/internal/commands/fuzz_test.go b/internal/commands/fuzz_test.go new file mode 100644 index 0000000..59e0e3e --- /dev/null +++ b/internal/commands/fuzz_test.go @@ -0,0 +1,49 @@ +package commands + +import "testing" + +func FuzzParsePseudoColumnID(f *testing.F) { + f.Add("") + f.Add("not-now") + f.Add("NOT_NOW") + f.Add("maybe") + f.Add("maybe?") + f.Add("triage") + f.Add("done") + f.Add("closed") + f.Add("close") + f.Add("random-string") + f.Add(" done ") + + f.Fuzz(func(t *testing.T, id string) { + parsePseudoColumnID(id) // must not panic + }) +} + +func FuzzNormalizeSkillPath(f *testing.F) { + f.Add("") + f.Add("~/skills") + f.Add("~/skills/fizzy") + f.Add("~/skills/fizzy/SKILL.md") + f.Add("/tmp/custom/path") + f.Add("/tmp/custom/path/fizzy") + f.Add("relative/path") + f.Add("file.md") + + f.Fuzz(func(t *testing.T, path string) { + normalizeSkillPath(path) // must not panic + }) +} + +func FuzzExpandPath(f *testing.F) { + f.Add("") + f.Add("~") + f.Add("~/") + f.Add("~/foo/bar") + f.Add("/absolute/path") + f.Add("relative/path") + + f.Fuzz(func(t *testing.T, path string) { + expandPath(path) // must not panic + }) +}