mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 05:56:38 +08:00
288b74d104
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}
723 lines
22 KiB
Go
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
|
|
}
|