v3 candidate

This commit is contained in:
Andy Dixon
2026-06-16 11:51:07 +01:00
parent fcb19c96c8
commit 493bfed599
91 changed files with 28824 additions and 80 deletions
+62 -32
View File
@@ -19,6 +19,11 @@ import (
"github.com/andydixon/nexus/internal/models"
)
const (
maxGitHubJSONBodyBytes = 4 << 20
maxGitHubErrorBodyBytes = 64 << 10
)
// Client wraps the GitHub REST API for repository listing.
type Client struct {
baseURL string
@@ -41,19 +46,19 @@ func New(baseURL, token string) *Client {
// apiRepo is the subset of the GitHub API response we care about.
type apiRepo struct {
FullName string `json:"full_name"`
Name string `json:"name"`
Description string `json:"description"`
HTMLURL string `json:"html_url"`
DefaultBranch string `json:"default_branch"`
Private bool `json:"private"`
Fork bool `json:"fork"`
Archived bool `json:"archived"`
Language string `json:"language"`
Stars int `json:"stargazers_count"`
PushedAt time.Time `json:"pushed_at"`
UpdatedAt time.Time `json:"updated_at"`
Owner struct {
FullName string `json:"full_name"`
Name string `json:"name"`
Description string `json:"description"`
HTMLURL string `json:"html_url"`
DefaultBranch string `json:"default_branch"`
Private bool `json:"private"`
Fork bool `json:"fork"`
Archived bool `json:"archived"`
Language string `json:"language"`
Stars int `json:"stargazers_count"`
PushedAt time.Time `json:"pushed_at"`
UpdatedAt time.Time `json:"updated_at"`
Owner struct {
Login string `json:"login"`
} `json:"owner"`
}
@@ -90,7 +95,7 @@ func (c *Client) fetchRepos(ctx context.Context, progress func(done int, total i
return nil, fmt.Errorf("fetching repos page %d: %w", page, err)
}
body, readErr := io.ReadAll(resp.Body)
body, readErr := readLimitedGitHubBody(resp.Body, maxGitHubJSONBodyBytes)
resp.Body.Close()
if readErr != nil {
return nil, fmt.Errorf("reading response body: %w", readErr)
@@ -128,19 +133,19 @@ func (c *Client) fetchRepos(ctx context.Context, progress func(done int, total i
result := make([]models.GitHubRepo, len(all))
for i, r := range all {
result[i] = models.GitHubRepo{
FullName: r.FullName,
Name: r.Name,
Owner: r.Owner.Login,
Description: r.Description,
HTMLURL: r.HTMLURL,
FullName: r.FullName,
Name: r.Name,
Owner: r.Owner.Login,
Description: r.Description,
HTMLURL: r.HTMLURL,
DefaultBranch: r.DefaultBranch,
Private: r.Private,
Fork: r.Fork,
Archived: r.Archived,
Language: r.Language,
Stars: r.Stars,
PushedAt: r.PushedAt.Format(time.RFC3339),
UpdatedAt: r.UpdatedAt.Format(time.RFC3339),
Private: r.Private,
Fork: r.Fork,
Archived: r.Archived,
Language: r.Language,
Stars: r.Stars,
PushedAt: r.PushedAt.Format(time.RFC3339),
UpdatedAt: r.UpdatedAt.Format(time.RFC3339),
}
}
@@ -208,8 +213,7 @@ func (c *Client) FetchLatestCommit(ctx context.Context, fullName string, ref str
return "", "", fmt.Errorf("authentication failed (HTTP %d)", resp.StatusCode)
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return "", "", fmt.Errorf("GitHub API returned HTTP %d: %s", resp.StatusCode, string(body))
return "", "", fmt.Errorf("GitHub API returned HTTP %d: %s", resp.StatusCode, readGitHubErrorBody(resp.Body))
}
var payload struct {
@@ -225,7 +229,11 @@ func (c *Client) FetchLatestCommit(ctx context.Context, fullName string, ref str
} `json:"commit"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
body, err := readLimitedGitHubBody(resp.Body, maxGitHubJSONBodyBytes)
if err != nil {
return "", "", fmt.Errorf("reading commit response: %w", err)
}
if err := json.Unmarshal(body, &payload); err != nil {
return "", "", fmt.Errorf("decoding commit: %w", err)
}
@@ -270,8 +278,7 @@ func (c *Client) DownloadRepoArchive(ctx context.Context, fullName string, ref s
return fmt.Errorf("authentication failed (HTTP %d) — check your access token", resp.StatusCode)
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("GitHub API returned HTTP %d: %s", resp.StatusCode, string(body))
return fmt.Errorf("GitHub API returned HTTP %d: %s", resp.StatusCode, readGitHubErrorBody(resp.Body))
}
file, err := os.Create(destPath)
@@ -310,8 +317,31 @@ func (c *Client) ValidateToken(ctx context.Context) (string, error) {
var user struct {
Login string `json:"login"`
}
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
body, err := readLimitedGitHubBody(resp.Body, maxGitHubJSONBodyBytes)
if err != nil {
return "", fmt.Errorf("reading user response: %w", err)
}
if err := json.Unmarshal(body, &user); err != nil {
return "", fmt.Errorf("decoding user: %w", err)
}
return user.Login, nil
}
func readLimitedGitHubBody(r io.Reader, maxBytes int64) ([]byte, error) {
body, err := io.ReadAll(io.LimitReader(r, maxBytes+1))
if err != nil {
return nil, err
}
if int64(len(body)) > maxBytes {
return nil, fmt.Errorf("response body exceeds %d bytes", maxBytes)
}
return body, nil
}
func readGitHubErrorBody(r io.Reader) string {
body, err := readLimitedGitHubBody(r, maxGitHubErrorBodyBytes)
if err != nil {
return "<response body unavailable: " + err.Error() + ">"
}
return strings.TrimSpace(string(body))
}
@@ -0,0 +1,26 @@
package githubclient
import (
"strings"
"testing"
)
func TestReadLimitedGitHubBodyRejectsOversizedResponse(t *testing.T) {
_, err := readLimitedGitHubBody(strings.NewReader("abcd"), 3)
if err == nil {
t.Fatal("expected oversized response to be rejected")
}
if !strings.Contains(err.Error(), "response body exceeds") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestReadLimitedGitHubBodyAllowsResponseAtLimit(t *testing.T) {
got, err := readLimitedGitHubBody(strings.NewReader("abc"), 3)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if string(got) != "abc" {
t.Fatalf("body = %q, want abc", string(got))
}
}