diff --git a/github/conformance_test.go b/github/conformance_test.go index 4dfa195..666bcab 100644 --- a/github/conformance_test.go +++ b/github/conformance_test.go @@ -1,5 +1,10 @@ package github_test -import "gitea.weiker.me/rodin/review-bot/vcs" +import ( + "gitea.weiker.me/rodin/review-bot/github" + "gitea.weiker.me/rodin/review-bot/vcs" +) -var _ vcs.PRReader = (*Client)(nil) +// Compile-time interface conformance assertion. +// Verifies github.Client satisfies vcs.PRReader. +var _ vcs.PRReader = (*github.Client)(nil) diff --git a/github/files.go b/github/files.go new file mode 100644 index 0000000..f7d415d --- /dev/null +++ b/github/files.go @@ -0,0 +1,68 @@ +package github + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/url" + "strings" +) + +// GetFileContentAtRef fetches a file at a specific ref from a repo. +// If ref is empty, the query parameter is omitted (uses default branch). +// +// Note: dot-segments ("." and "..") in the path are silently removed to +// prevent path traversal. This means a path like "foo/../bar" resolves +// to "foo/bar" rather than "bar". +func (c *Client) GetFileContentAtRef(ctx context.Context, owner, repo, path, ref string) (string, error) { + reqURL := fmt.Sprintf("%s/repos/%s/%s/contents/%s", + c.baseURL, url.PathEscape(owner), url.PathEscape(repo), escapePath(path)) + if ref != "" { + reqURL += "?ref=" + url.QueryEscape(ref) + } + body, err := c.doGet(ctx, reqURL) + if err != nil { + return "", fmt.Errorf("fetch file %s: %w", path, err) + } + var resp struct { + Content string `json:"content"` + Encoding string `json:"encoding"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return "", fmt.Errorf("parse file content JSON: %w", err) + } + if resp.Encoding != "base64" { + return "", fmt.Errorf("unexpected encoding %q for file %s", resp.Encoding, path) + } + decoded, err := decodeBase64Content(resp.Content) + if err != nil { + return "", fmt.Errorf("decode base64 content for %s: %w", path, err) + } + return decoded, nil +} + +// escapePath encodes each segment of a slash-separated path, stripping +// dot-segments to prevent path traversal. +func escapePath(p string) string { + parts := strings.Split(p, "/") + var clean []string + for _, part := range parts { + if part == "." || part == ".." || part == "" { + continue + } + clean = append(clean, url.PathEscape(part)) + } + return strings.Join(clean, "/") +} + +// decodeBase64Content decodes base64-encoded content from the GitHub contents API. +// GitHub returns base64 content with line breaks for formatting; we strip \r and \n before decoding. +func decodeBase64Content(encoded string) (string, error) { + cleaned := strings.NewReplacer("\n", "", "\r", "").Replace(encoded) + decoded, err := base64.StdEncoding.DecodeString(cleaned) + if err != nil { + return "", err + } + return string(decoded), nil +}