package github import ( "context" "encoding/base64" "encoding/json" "fmt" "net/url" "path" "strings" ) // GetFileContentAtRef fetches a file at a specific ref from a repo. // If ref is empty, the query parameter is omitted (uses default branch). // // Returns an error if the path contains dot-segments (".", "..") or // attempts to traverse above the repository root. func (c *Client) GetFileContentAtRef(ctx context.Context, owner, repo, filePath, ref string) (string, error) { escaped, err := escapePath(filePath) if err != nil { return "", fmt.Errorf("invalid file path: %w", err) } reqURL := fmt.Sprintf("%s/repos/%s/%s/contents/%s", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), escaped) if ref != "" { reqURL += "?ref=" + url.QueryEscape(ref) } body, err := c.doGet(ctx, reqURL) if err != nil { return "", fmt.Errorf("fetch file %s: %w", filePath, 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, filePath) } decoded, err := decodeBase64Content(resp.Content) if err != nil { return "", fmt.Errorf("decode base64 content for %s: %w", filePath, err) } return decoded, nil } // escapePath validates and encodes a slash-separated file path for use in // GitHub API URLs. Returns an error if the path contains dot-segments ("." // or "..") or resolves to a path outside the repository root. func escapePath(p string) (string, error) { // Reject paths containing dot-segments rather than silently rewriting them. for _, seg := range strings.Split(p, "/") { if seg == "." || seg == ".." { return "", fmt.Errorf("path contains dot-segment %q: %s", seg, p) } } // Use path.Clean for canonical form, then verify it doesn't escape root. cleaned := path.Clean(p) if cleaned == "." || strings.HasPrefix(cleaned, "..") { return "", fmt.Errorf("path resolves outside repository root: %s", p) } // Encode each segment individually. parts := strings.Split(cleaned, "/") var encoded []string for _, part := range parts { if part == "" { continue } encoded = append(encoded, url.PathEscape(part)) } return strings.Join(encoded, "/"), nil } // 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 }