Files
OpenFlare/internal/integration/githubrelease/client.go
T
ryan 288b74d104 intrange 3→0 + modernize 5→3:for i:=0;i<len/N;i++ → range len/N(8 处);time.Time 字段 omitempty→omitzero(wire 输出一致);SplitSeq;min() 简化。刻意保留 lark.go omitzero(会改变 wire 行为)。
Result: {"status":"keep","total_issues":33,"golint_canonicalheader":0,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":32,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":45}
2026-08-16 17:47:09 +08:00

723 lines
22 KiB
Go

// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package githubrelease resolves and downloads public GitHub Release assets.
// It deliberately does not know about Pages projects, deployments or runtime
// state so other callers can reuse the same constrained HTTP contract.
package githubrelease
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"math"
"net/http"
"net/url"
"os"
"path"
"regexp"
"strconv"
"strings"
"time"
"unicode"
"unicode/utf8"
)
const (
// APIVersion is the GitHub REST API contract used by this package.
APIVersion = "2026-03-10"
// SelectorLatest uses GitHub's repository latest-release endpoint.
SelectorLatest Selector = "latest"
// SelectorTag resolves one exact GitHub release tag.
SelectorTag Selector = "tag"
defaultAPIBaseURL = "https://api.github.com"
defaultUserAgent = "OpenFlare-GitHubRelease/1.0"
metadataAccept = "application/vnd.github+json"
assetAccept = "application/octet-stream"
maxMetadataBytes = 4 << 20
maxAssetErrorNames = 10
maxSafeTextBytes = 255
maxSafeAssetNameLen = 96
maxDigestBytes = 96
maxETagBytes = 512
safePartsCapacity = 6
)
var (
errInvalidRequest = errors.New("GitHub Release 请求参数无效")
errMetadata = errors.New("GitHub Release 元数据响应无效")
errAssetMissing = errors.New("GitHub Release 中未找到指定的已上传 asset")
errDownload = errors.New("GitHub Release asset 下载失败")
errTooLarge = errors.New("GitHub Release asset 超过大小限制")
errEmptyAsset = errors.New("GitHub Release asset 内容为空")
errDigest = errors.New("GitHub Release asset digest 无效或校验失败")
errCleanup = errors.New("GitHub Release 临时文件清理失败")
ownerPattern = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$`)
repoPattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
hexPattern = regexp.MustCompile(`^[0-9a-fA-F]{64}$`)
)
var (
// ErrInvalidRequest identifies caller configuration errors.
ErrInvalidRequest = errInvalidRequest
// ErrMetadata identifies malformed, unavailable or failed Release metadata requests.
ErrMetadata = errMetadata
// ErrAssetNotFound identifies an otherwise valid Release without the exact uploaded asset.
ErrAssetNotFound = errAssetMissing
// ErrDownload identifies network or HTTP failures while downloading an asset.
ErrDownload = errDownload
// ErrAssetTooLarge identifies assets that exceed the caller's hard byte limit.
ErrAssetTooLarge = errTooLarge
// ErrEmptyAsset identifies an empty downloaded asset.
ErrEmptyAsset = errEmptyAsset
// ErrDigestMismatch identifies malformed or mismatched declared SHA-256 digests.
ErrDigestMismatch = errDigest
)
// Selector identifies GitHub's own latest endpoint or one exact tag.
type Selector string
// ResolveRequest describes one public repository release asset lookup.
type ResolveRequest struct {
Repository string
Selector Selector
Tag string
AssetName string
ETag string
}
// Release contains only metadata safe and necessary for source resolution.
type Release struct {
ID string `json:"release_id"`
Tag string `json:"tag"`
Name string `json:"name,omitempty"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
PublishedAt time.Time `json:"published_at,omitzero"`
}
// Asset contains the immutable target metadata returned by a resolve call.
type Asset struct {
ID string `json:"asset_id"`
Name string `json:"asset_name"`
State string `json:"state"`
Size int64 `json:"size"`
UpdatedAt time.Time `json:"updated_at,omitzero"`
Digest string `json:"digest,omitempty"`
}
// ResolveResult is either a selected uploaded asset or a not-modified marker.
type ResolveResult struct {
NotModified bool `json:"not_modified"`
ETag string `json:"etag,omitempty"`
Release Release `json:"release,omitempty"`
Asset Asset `json:"asset,omitempty"`
RetryAt *time.Time `json:"retry_at,omitempty"`
}
// DownloadRequest identifies an already resolved asset. Asset IDs never come
// from an untrusted URL and the download endpoint is built locally.
type DownloadRequest struct {
Repository string
Asset Asset
MaxBytes int64
}
// DownloadResult owns a temporary file. Call Cleanup after ingestion.
type DownloadResult struct {
Path string
Size int64
SHA256 string
DeclaredDigest string
}
// Cleanup removes the temporary file and is safe to call more than once.
func (result *DownloadResult) Cleanup() error {
if result == nil || result.Path == "" {
return nil
}
name := result.Path
err := os.Remove(name)
if err == nil || errors.Is(err, os.ErrNotExist) {
result.Path = ""
return nil
}
return errCleanup
}
// Error is a safe provider error. It never retains a response body, request
// URL, redirect location or request headers.
type Error struct {
Kind error
StatusCode int
RequestID string
Repository string
Tag string
AssetName string
AvailableAssets []string
RetryAt *time.Time
}
func (providerError *Error) Error() string {
if providerError == nil {
return "GitHub Release 请求失败"
}
message := "GitHub Release 请求失败"
if providerError.Kind != nil {
message = providerError.Kind.Error()
}
parts := make([]string, 0, safePartsCapacity)
if providerError.StatusCode != 0 {
parts = append(parts, "status="+strconv.Itoa(providerError.StatusCode))
}
if providerError.RequestID != "" {
parts = append(parts, "request_id="+providerError.RequestID)
}
if providerError.Repository != "" {
parts = append(parts, "repo="+providerError.Repository)
}
if providerError.Tag != "" {
parts = append(parts, "tag="+providerError.Tag)
}
if providerError.AssetName != "" {
parts = append(parts, "asset="+providerError.AssetName)
}
if len(providerError.AvailableAssets) > 0 {
parts = append(parts, "available="+strings.Join(providerError.AvailableAssets, ","))
}
if len(parts) == 0 {
return message
}
return message + " (" + strings.Join(parts, " ") + ")"
}
func (providerError *Error) Unwrap() error {
if providerError == nil {
return nil
}
return providerError.Kind
}
// RetryAt extracts the server-directed retry deadline from an error.
func RetryAt(err error) (time.Time, bool) {
var providerError *Error
if !errors.As(err, &providerError) || providerError.RetryAt == nil {
return time.Time{}, false
}
return *providerError.RetryAt, true
}
// RetryTime is retained as a compatibility alias for early callers.
//
// Deprecated: use RetryAt.
func RetryTime(err error) (time.Time, bool) {
return RetryAt(err)
}
// IsNotFound reports both a missing Release endpoint and a Release that lacks
// the exact uploaded asset requested by the caller.
func IsNotFound(err error) bool {
if errors.Is(err, ErrAssetNotFound) {
return true
}
var providerError *Error
return errors.As(err, &providerError) && providerError.StatusCode == http.StatusNotFound
}
// IsDigestError reports malformed or mismatched declared asset digests.
func IsDigestError(err error) bool {
return errors.Is(err, ErrDigestMismatch)
}
// IsRetryable classifies provider failures without relying on localized error
// strings. Configuration, not-found, size, empty-content and digest failures
// are permanent. Network failures, 408/425/429 and 5xx responses are retryable.
func IsRetryable(err error) bool {
if err == nil || errors.Is(err, ErrInvalidRequest) || IsNotFound(err) ||
errors.Is(err, ErrAssetTooLarge) || errors.Is(err, ErrEmptyAsset) || IsDigestError(err) {
return false
}
var providerError *Error
if !errors.As(err, &providerError) {
return false
}
if providerError.StatusCode == 0 {
return errors.Is(err, ErrMetadata) || errors.Is(err, ErrDownload) || errors.Is(err, errCleanup)
}
if providerError.StatusCode < http.StatusBadRequest {
return errors.Is(err, ErrMetadata) || errors.Is(err, ErrDownload) || errors.Is(err, errCleanup)
}
if providerError.RetryAt != nil {
return true
}
return providerError.StatusCode == http.StatusRequestTimeout ||
providerError.StatusCode == http.StatusTooEarly ||
providerError.StatusCode == http.StatusTooManyRequests ||
providerError.StatusCode >= http.StatusInternalServerError
}
// Client accesses public GitHub Releases using a fixed, constrained transport.
type Client struct {
httpClient *http.Client
baseURL string
createTemp func(string, string) (*os.File, error)
now func() time.Time
}
// NewClient constructs a production client for api.github.com. Public
// repositories do not require or send a token.
func NewClient() *Client {
return newClient(defaultClientOptions())
}
// Resolve calls GitHub's latest or exact-tag endpoint and selects one exact,
// case-sensitive uploaded asset. It never falls back to source archives.
func (client *Client) Resolve(ctx context.Context, request ResolveRequest) (ResolveResult, error) {
repository, tag, endpoint, err := normalizeResolveRequest(client.baseURL, request)
if err != nil {
return ResolveResult{}, safeError(errInvalidRequest, 0, "", repository, tag, validErrorAssetName(request.AssetName), nil, nil)
}
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return ResolveResult{}, safeError(errInvalidRequest, 0, "", repository, tag, validErrorAssetName(request.AssetName), nil, nil)
}
applyMetadataHeaders(httpRequest, request.ETag)
response, err := client.httpClient.Do(httpRequest) //nolint:gosec // endpoint and every dial target are constrained
if err != nil {
return ResolveResult{}, safeError(errMetadata, 0, "", repository, tag, request.AssetName, nil, nil)
}
defer func() { _ = response.Body.Close() }()
retryAt := responseRetryAt(response, client.now())
etag := safeETag(response.Header.Get("ETag"))
if response.StatusCode == http.StatusNotModified {
if etag == "" {
etag = safeETag(request.ETag)
}
return ResolveResult{NotModified: true, ETag: etag, RetryAt: retryAt}, nil
}
if response.StatusCode != http.StatusOK {
return ResolveResult{}, safeHTTPError(errMetadata, response, repository, tag, request.AssetName, retryAt)
}
body, readErr := io.ReadAll(io.LimitReader(response.Body, maxMetadataBytes+1))
if readErr != nil || len(body) > maxMetadataBytes || !utf8.Valid(body) {
return ResolveResult{}, safeHTTPError(errMetadata, response, repository, tag, request.AssetName, retryAt)
}
var payload releasePayload
decoder := json.NewDecoder(bytes.NewReader(body))
decoder.UseNumber()
if err := decoder.Decode(&payload); err != nil {
return ResolveResult{}, safeHTTPError(errMetadata, response, repository, tag, request.AssetName, retryAt)
}
if err := ensureJSONEOF(decoder); err != nil {
return ResolveResult{}, safeHTTPError(errMetadata, response, repository, tag, request.AssetName, retryAt)
}
release, assets, err := convertRelease(payload)
if err != nil {
return ResolveResult{}, safeHTTPError(errMetadata, response, repository, tag, request.AssetName, retryAt)
}
for _, asset := range assets {
if asset.State == "uploaded" && asset.Name == request.AssetName {
return ResolveResult{
ETag: etag,
Release: release,
Asset: asset,
RetryAt: retryAt,
}, nil
}
}
available := safeAssetNames(assets)
return ResolveResult{}, safeError(
errAssetMissing,
response.StatusCode,
response.Header.Get("X-Github-Request-Id"),
repository,
release.Tag,
request.AssetName,
available,
retryAt,
)
}
// Download streams an asset into a package-owned temporary file while
// enforcing a hard byte limit and verifying GitHub's declared sha256 digest.
func (client *Client) Download(ctx context.Context, request DownloadRequest) (*DownloadResult, error) {
repository, err := normalizeRepository(request.Repository)
if err != nil || request.MaxBytes <= 0 || !validPositiveID(request.Asset.ID) ||
!validAssetName(request.Asset.Name) || request.Asset.Size < 0 {
return nil, safeError(errInvalidRequest, 0, "", repository, "", validErrorAssetName(request.Asset.Name), nil, nil)
}
if request.Asset.Size > request.MaxBytes {
return nil, safeError(errTooLarge, 0, "", repository, "", validErrorAssetName(request.Asset.Name), nil, nil)
}
endpoint := strings.TrimRight(client.baseURL, "/") + "/repos/" + repository + "/releases/assets/" + request.Asset.ID
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, safeError(errInvalidRequest, 0, "", repository, "", request.Asset.Name, nil, nil)
}
applyAssetHeaders(httpRequest)
response, err := client.httpClient.Do(httpRequest) //nolint:gosec // endpoint and every dial target are constrained
if err != nil {
return nil, safeError(errDownload, 0, "", repository, "", request.Asset.Name, nil, nil)
}
defer func() { _ = response.Body.Close() }()
retryAt := responseRetryAt(response, client.now())
if response.StatusCode != http.StatusOK {
return nil, safeHTTPError(errDownload, response, repository, "", request.Asset.Name, retryAt)
}
if response.ContentLength > request.MaxBytes {
return nil, safeHTTPError(errTooLarge, response, repository, "", request.Asset.Name, retryAt)
}
result, err := client.streamAsset(response.Body, request.MaxBytes, request.Asset.Digest)
if err != nil {
return nil, safeError(err, response.StatusCode, response.Header.Get("X-Github-Request-Id"), repository, "", request.Asset.Name, nil, retryAt)
}
return result, nil
}
func (client *Client) streamAsset(body io.Reader, maxBytes int64, declaredDigest string) (result *DownloadResult, resultErr error) {
tempFile, err := client.createTemp("", "openflare-github-release-*")
if err != nil {
return nil, errDownload
}
tempPath := tempFile.Name()
defer func() {
closeErr := tempFile.Close()
if resultErr == nil && closeErr != nil {
resultErr = errDownload
}
if resultErr != nil {
if removeErr := os.Remove(tempPath); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) {
resultErr = errCleanup
}
}
}()
hasher := sha256.New()
readLimit := maxBytes
if readLimit < math.MaxInt64 {
readLimit++
}
size, err := io.Copy(io.MultiWriter(tempFile, hasher), io.LimitReader(body, readLimit))
if err != nil {
return nil, errDownload
}
if size > maxBytes {
return nil, errTooLarge
}
if size == 0 {
return nil, errEmptyAsset
}
checksum := hex.EncodeToString(hasher.Sum(nil))
if err := verifyDeclaredDigest(declaredDigest, checksum); err != nil {
return nil, err
}
return &DownloadResult{
Path: tempPath,
Size: size,
SHA256: checksum,
DeclaredDigest: strings.ToLower(strings.TrimSpace(declaredDigest)),
}, nil
}
type releasePayload struct {
ID json.Number `json:"id"`
Tag string `json:"tag_name"`
Name string `json:"name"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
PublishedAt string `json:"published_at"`
Assets []assetPayload `json:"assets"`
}
type assetPayload struct {
ID json.Number `json:"id"`
Name string `json:"name"`
State string `json:"state"`
Size int64 `json:"size"`
UpdatedAt string `json:"updated_at"`
Digest string `json:"digest"`
}
func convertRelease(payload releasePayload) (Release, []Asset, error) {
releaseID, err := positiveJSONID(payload.ID)
if err != nil {
return Release{}, nil, err
}
if !validReleaseDisplayTag(payload.Tag) {
return Release{}, nil, errMetadata
}
publishedAt, err := parseOptionalTime(payload.PublishedAt)
if err != nil {
return Release{}, nil, err
}
release := Release{
ID: releaseID,
Tag: payload.Tag,
Name: safeText(payload.Name, maxSafeTextBytes),
Draft: payload.Draft,
Prerelease: payload.Prerelease,
PublishedAt: publishedAt,
}
assets := make([]Asset, 0, len(payload.Assets))
for _, rawAsset := range payload.Assets {
assetID, assetErr := positiveJSONID(rawAsset.ID)
if assetErr != nil || rawAsset.Size < 0 {
return Release{}, nil, errMetadata
}
updatedAt, assetErr := parseOptionalTime(rawAsset.UpdatedAt)
if assetErr != nil {
return Release{}, nil, errMetadata
}
assets = append(assets, Asset{
ID: assetID,
Name: rawAsset.Name,
State: rawAsset.State,
Size: rawAsset.Size,
UpdatedAt: updatedAt,
Digest: safeText(rawAsset.Digest, maxDigestBytes),
})
}
return release, assets, nil
}
func normalizeResolveRequest(baseURL string, request ResolveRequest) (string, string, string, error) {
repository, err := normalizeRepository(request.Repository)
if err != nil || !validAssetName(request.AssetName) {
return repository, validErrorTag(request.Tag), "", errInvalidRequest
}
baseURL = strings.TrimRight(baseURL, "/")
switch request.Selector {
case SelectorLatest:
if strings.TrimSpace(request.Tag) != "" {
return repository, "", "", errInvalidRequest
}
return repository, "latest", baseURL + "/repos/" + repository + "/releases/latest", nil
case SelectorTag:
if !validTag(request.Tag) {
return repository, validErrorTag(request.Tag), "", errInvalidRequest
}
return repository, request.Tag, baseURL + "/repos/" + repository + "/releases/tags/" + url.PathEscape(request.Tag), nil
default:
return repository, validErrorTag(request.Tag), "", errInvalidRequest
}
}
func normalizeRepository(repository string) (string, error) {
repository = strings.TrimSpace(repository)
parts := strings.Split(repository, "/")
if len(parts) != 2 || !ownerPattern.MatchString(parts[0]) || !repoPattern.MatchString(parts[1]) ||
len(parts[1]) > 100 || parts[1] == "." || parts[1] == ".." {
return "", errInvalidRequest
}
return parts[0] + "/" + parts[1], nil
}
func validAssetName(assetName string) bool {
return validLogText(assetName, maxSafeTextBytes, false) && path.Base(assetName) == assetName &&
assetName != "." && assetName != ".." && !strings.ContainsAny(assetName, `/\`)
}
func validTag(tag string) bool {
if !validLogText(tag, maxSafeTextBytes, false) || strings.ContainsAny(tag, " ~^:?*[\\") ||
strings.Contains(tag, "..") || strings.Contains(tag, "@{") || strings.Contains(tag, "//") ||
strings.HasPrefix(tag, "/") || strings.HasSuffix(tag, "/") || strings.HasSuffix(tag, ".") {
return false
}
for component := range strings.SplitSeq(tag, "/") {
if component == "" || strings.HasPrefix(component, ".") || strings.HasSuffix(component, ".lock") {
return false
}
}
return true
}
func validReleaseDisplayTag(tag string) bool {
return validLogText(tag, maxSafeTextBytes, false)
}
func validLogText(value string, maxBytes int, allowEmpty bool) bool {
if (!allowEmpty && value == "") || len(value) > maxBytes || !utf8.ValidString(value) {
return false
}
for _, character := range value {
if isLogControl(character) {
return false
}
}
return true
}
func isLogControl(character rune) bool {
if unicode.IsControl(character) || character == '\u2028' || character == '\u2029' {
return true
}
switch character {
case '\u061c', '\u200e', '\u200f',
'\u202a', '\u202b', '\u202c', '\u202d', '\u202e',
'\u2066', '\u2067', '\u2068', '\u2069':
return true
default:
return false
}
}
func validErrorTag(tag string) string {
if !validTag(tag) || containsSecretDelimiter(tag) {
return ""
}
return tag
}
func validErrorAssetName(assetName string) string {
if !validAssetName(assetName) || containsSecretDelimiter(assetName) {
return ""
}
return assetName
}
func containsSecretDelimiter(value string) bool {
return strings.ContainsAny(value, "?&=#") || strings.Contains(value, "://")
}
func validPositiveID(id string) bool {
parsed, err := strconv.ParseInt(id, 10, 64)
return err == nil && parsed > 0 && strconv.FormatInt(parsed, 10) == id
}
func positiveJSONID(id json.Number) (string, error) {
parsed, err := strconv.ParseInt(id.String(), 10, 64)
if err != nil || parsed <= 0 {
return "", errMetadata
}
return strconv.FormatInt(parsed, 10), nil
}
func parseOptionalTime(value string) (time.Time, error) {
if value == "" {
return time.Time{}, nil
}
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
return time.Time{}, errMetadata
}
return parsed, nil
}
func ensureJSONEOF(decoder *json.Decoder) error {
var trailing any
if err := decoder.Decode(&trailing); errors.Is(err, io.EOF) {
return nil
}
return errMetadata
}
func verifyDeclaredDigest(declaredDigest string, checksum string) error {
declaredDigest = strings.TrimSpace(declaredDigest)
if declaredDigest == "" {
return nil
}
algorithm, digest, ok := strings.Cut(declaredDigest, ":")
if !ok || !strings.EqualFold(algorithm, "sha256") || !hexPattern.MatchString(digest) ||
!strings.EqualFold(digest, checksum) {
return errDigest
}
return nil
}
func safeAssetNames(assets []Asset) []string {
count := min(len(assets), maxAssetErrorNames)
names := make([]string, 0, count)
for _, asset := range assets[:count] {
name := safeText(asset.Name, maxSafeAssetNameLen)
if containsSecretDelimiter(name) {
name = "<redacted>"
}
names = append(names, name)
}
return names
}
func safeText(value string, maxBytes int) string {
var builder strings.Builder
for _, character := range value {
if isLogControl(character) {
builder.WriteByte('?')
continue
}
builder.WriteRune(character)
if builder.Len() >= maxBytes {
break
}
}
result := builder.String()
for len(result) > maxBytes {
_, size := utf8.DecodeLastRuneInString(result)
result = result[:len(result)-size]
}
return result
}
func safeETag(value string) string {
value = strings.TrimSpace(value)
if len(value) > maxETagBytes || safeText(value, maxETagBytes) != value {
return ""
}
return value
}
func safeHTTPError(kind error, response *http.Response, repository string, tag string, assetName string, retryAt *time.Time) error {
return safeError(
kind,
response.StatusCode,
response.Header.Get("X-Github-Request-Id"),
repository,
tag,
assetName,
nil,
retryAt,
)
}
func safeError(
kind error,
statusCode int,
requestID string,
repository string,
tag string,
assetName string,
availableAssets []string,
retryAt *time.Time,
) error {
return &Error{
Kind: kind,
StatusCode: statusCode,
RequestID: safeErrorToken(requestID, maxSafeTextBytes),
Repository: safeErrorToken(repository, maxSafeTextBytes),
Tag: safeErrorToken(tag, maxSafeTextBytes),
AssetName: safeErrorToken(assetName, maxSafeAssetNameLen),
AvailableAssets: availableAssets,
RetryAt: retryAt,
}
}
func safeErrorToken(value string, maxBytes int) string {
if !validLogText(value, maxBytes, true) {
return ""
}
value = safeText(value, maxBytes)
if containsSecretDelimiter(value) {
return ""
}
return value
}