Inspect hosted assets against build manifests
This commit is contained in:
266
internal/gitea/assets.go
Normal file
266
internal/gitea/assets.go
Normal file
@@ -0,0 +1,266 @@
|
||||
package gitea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const ManifestFilename = "SHA256SUMS"
|
||||
|
||||
type AssetState string
|
||||
|
||||
const (
|
||||
AssetMatching AssetState = "matching"
|
||||
AssetMissing AssetState = "missing"
|
||||
AssetConflicting AssetState = "conflicting"
|
||||
AssetUnverifiable AssetState = "unverifiable"
|
||||
)
|
||||
|
||||
// ExpectedAsset describes one declared file. Content is populated for the
|
||||
// generated checksum manifest; Path identifies local binary content for later
|
||||
// upload operations.
|
||||
type ExpectedAsset struct {
|
||||
Filename string
|
||||
SHA256 string
|
||||
Size int64
|
||||
Path string
|
||||
Content []byte
|
||||
}
|
||||
|
||||
type RemoteAsset struct {
|
||||
ID int64
|
||||
Filename string
|
||||
Size int64
|
||||
DownloadURL string
|
||||
}
|
||||
|
||||
type AssetInspection struct {
|
||||
Expected ExpectedAsset
|
||||
State AssetState
|
||||
Existing []RemoteAsset
|
||||
Err error
|
||||
}
|
||||
|
||||
type Inspection struct {
|
||||
Assets []AssetInspection
|
||||
Unrelated []RemoteAsset
|
||||
}
|
||||
|
||||
type assetResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
}
|
||||
|
||||
// BuildManifest creates the deterministic checksum file for the declared
|
||||
// binaries. An empty binary set produces no manifest.
|
||||
func BuildManifest(binaries []ExpectedAsset) (ExpectedAsset, error) {
|
||||
if len(binaries) == 0 {
|
||||
return ExpectedAsset{}, nil
|
||||
}
|
||||
ordered := append([]ExpectedAsset(nil), binaries...)
|
||||
sort.Slice(ordered, func(i, j int) bool { return ordered[i].Filename < ordered[j].Filename })
|
||||
var body strings.Builder
|
||||
seen := make(map[string]struct{}, len(ordered))
|
||||
for _, binary := range ordered {
|
||||
if !validAssetName(binary.Filename) || binary.Filename == ManifestFilename || !validSHA256(binary.SHA256) {
|
||||
return ExpectedAsset{}, failure(FailureInvalid, "build checksum manifest", 0, fmt.Errorf("invalid binary identity for %q", binary.Filename))
|
||||
}
|
||||
if _, exists := seen[binary.Filename]; exists {
|
||||
return ExpectedAsset{}, failure(FailureInvalid, "build checksum manifest", 0, fmt.Errorf("duplicate binary filename %q", binary.Filename))
|
||||
}
|
||||
seen[binary.Filename] = struct{}{}
|
||||
fmt.Fprintf(&body, "%s %s\n", binary.SHA256, binary.Filename)
|
||||
}
|
||||
content := []byte(body.String())
|
||||
return ExpectedAsset{Filename: ManifestFilename, SHA256: digest(content), Size: int64(len(content)), Content: content}, nil
|
||||
}
|
||||
|
||||
// InspectAssets lists the release's attachments and compares every declared
|
||||
// asset by streamed SHA-256 content. It does not create or modify remote data.
|
||||
func (c Client) InspectAssets(ctx context.Context, destination Destination, releaseID int64, expected []ExpectedAsset) (Inspection, error) {
|
||||
if releaseID <= 0 {
|
||||
return Inspection{}, failure(FailureInvalid, "inspect release assets", 0, errors.New("release ID must be positive"))
|
||||
}
|
||||
if strings.TrimSpace(destination.Token) == "" {
|
||||
return Inspection{}, failure(FailureInvalid, "inspect release assets", 0, errors.New("Gitea token is required"))
|
||||
}
|
||||
base, err := parseBaseURL(destination.BaseURL)
|
||||
if err != nil {
|
||||
return Inspection{}, failure(FailureInvalid, "validate destination", 0, err)
|
||||
}
|
||||
if strings.TrimSpace(destination.Owner) == "" || strings.ContainsAny(destination.Owner, "/\\") || strings.TrimSpace(destination.Repository) == "" || strings.ContainsAny(destination.Repository, "/\\") {
|
||||
return Inspection{}, failure(FailureInvalid, "inspect release assets", 0, errors.New("Gitea owner and repository must be path segments"))
|
||||
}
|
||||
if err := validateExpectedAssets(expected); err != nil {
|
||||
return Inspection{}, err
|
||||
}
|
||||
apiRoot := strings.TrimRight(base.String(), "/") + "/api/v1/repos/" + url.PathEscape(destination.Owner) + "/" + url.PathEscape(destination.Repository)
|
||||
assetsURL := apiRoot + "/releases/" + strconv.FormatInt(releaseID, 10) + "/assets"
|
||||
var listed []assetResponse
|
||||
if err := c.getJSON(ctx, c.httpClient(), destination.Token, "list release assets", assetsURL, &listed); err != nil {
|
||||
return Inspection{}, err
|
||||
}
|
||||
if listed == nil {
|
||||
return Inspection{}, failure(FailureMalformed, "decode release assets", http.StatusOK, errors.New("attachment response must be a JSON array"))
|
||||
}
|
||||
|
||||
remoteByName := make(map[string][]RemoteAsset, len(listed))
|
||||
for _, item := range listed {
|
||||
if item.ID <= 0 || item.Name == "" || item.Size < 0 {
|
||||
return Inspection{}, failure(FailureMalformed, "decode release assets", http.StatusOK, errors.New("attachment entry is missing its ID, name, or size"))
|
||||
}
|
||||
remote := RemoteAsset{ID: item.ID, Filename: item.Name, Size: item.Size, DownloadURL: item.BrowserDownloadURL}
|
||||
remoteByName[item.Name] = append(remoteByName[item.Name], remote)
|
||||
}
|
||||
inspection := Inspection{Assets: make([]AssetInspection, 0, len(expected))}
|
||||
declared := make(map[string]struct{}, len(expected))
|
||||
for _, item := range expected {
|
||||
declared[item.Filename] = struct{}{}
|
||||
matches := remoteByName[item.Filename]
|
||||
observation := AssetInspection{Expected: item, Existing: matches}
|
||||
switch len(matches) {
|
||||
case 0:
|
||||
observation.State = AssetMissing
|
||||
case 1:
|
||||
remoteHash, _, err := c.downloadHash(ctx, c.httpClient(), base, destination.Token, matches[0].DownloadURL)
|
||||
if err != nil {
|
||||
observation.State = AssetUnverifiable
|
||||
observation.Err = err
|
||||
} else if remoteHash == item.SHA256 {
|
||||
observation.State = AssetMatching
|
||||
} else {
|
||||
observation.State = AssetConflicting
|
||||
}
|
||||
default:
|
||||
observation.State = AssetConflicting
|
||||
}
|
||||
inspection.Assets = append(inspection.Assets, observation)
|
||||
}
|
||||
for _, item := range listed {
|
||||
if _, expected := declared[item.Name]; !expected {
|
||||
inspection.Unrelated = append(inspection.Unrelated, RemoteAsset{ID: item.ID, Filename: item.Name, Size: item.Size, DownloadURL: item.BrowserDownloadURL})
|
||||
}
|
||||
}
|
||||
return inspection, nil
|
||||
}
|
||||
|
||||
func (c Client) downloadHash(ctx context.Context, client *http.Client, base *url.URL, token, rawURL string) (string, int64, error) {
|
||||
if strings.TrimSpace(rawURL) == "" {
|
||||
return "", 0, failure(FailureUnverifiable, "download release asset", 0, errors.New("attachment has no download URL"))
|
||||
}
|
||||
assetURL, err := base.Parse(rawURL)
|
||||
if err != nil || !sameOrigin(base, assetURL) || assetURL.User != nil {
|
||||
return "", 0, failure(FailureUnverifiable, "download release asset", 0, errors.New("asset URL is outside the configured service origin"))
|
||||
}
|
||||
assetURL.Fragment = ""
|
||||
visited := make(map[string]struct{})
|
||||
for {
|
||||
key := assetURL.String()
|
||||
if _, exists := visited[key]; exists {
|
||||
return "", 0, failure(FailureUnverifiable, "download release asset", 0, errors.New("asset redirect loop"))
|
||||
}
|
||||
visited[key] = struct{}{}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, key, nil)
|
||||
if err != nil {
|
||||
return "", 0, failure(FailureUnverifiable, "download release asset", 0, err)
|
||||
}
|
||||
request.Header.Set("Authorization", "token "+token)
|
||||
request.Header.Set("Accept", "application/octet-stream")
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return "", 0, failure(FailureUnverifiable, "download release asset", 0, err)
|
||||
}
|
||||
if isRedirect(response.StatusCode) {
|
||||
location, locationErr := response.Location()
|
||||
_ = response.Body.Close()
|
||||
if locationErr != nil || !sameOrigin(base, location) || location.User != nil {
|
||||
return "", 0, failure(FailureUnverifiable, "redirect release asset", response.StatusCode, errors.New("asset redirect is outside the configured service origin"))
|
||||
}
|
||||
location.Fragment = ""
|
||||
assetURL = location
|
||||
continue
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return "", 0, responseFailure("download release asset", response.StatusCode)
|
||||
}
|
||||
hasher := sha256.New()
|
||||
size, err := io.Copy(hasher, response.Body)
|
||||
if err != nil {
|
||||
return "", size, failure(FailureUnverifiable, "read release asset", response.StatusCode, err)
|
||||
}
|
||||
return hex.EncodeToString(hasher.Sum(nil)), size, nil
|
||||
}
|
||||
}
|
||||
|
||||
func validateExpectedAssets(expected []ExpectedAsset) error {
|
||||
seen := make(map[string]struct{}, len(expected))
|
||||
for _, item := range expected {
|
||||
if !validAssetName(item.Filename) || !validSHA256(item.SHA256) || item.Size < 0 {
|
||||
return failure(FailureInvalid, "inspect release assets", 0, fmt.Errorf("invalid expected asset %q", item.Filename))
|
||||
}
|
||||
if _, duplicate := seen[item.Filename]; duplicate {
|
||||
return failure(FailureInvalid, "inspect release assets", 0, fmt.Errorf("duplicate expected asset %q", item.Filename))
|
||||
}
|
||||
seen[item.Filename] = struct{}{}
|
||||
if len(item.Content) > 0 && (int64(len(item.Content)) != item.Size || digest(item.Content) != item.SHA256) {
|
||||
return failure(FailureInvalid, "inspect release assets", 0, fmt.Errorf("expected content does not match identity for %q", item.Filename))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validAssetName(name string) bool {
|
||||
return name != "" && name != "." && name != ".." && path.Base(name) == name && !strings.ContainsAny(name, "\\\r\n\x00")
|
||||
}
|
||||
|
||||
func validSHA256(value string) bool {
|
||||
if len(value) != sha256.Size*2 {
|
||||
return false
|
||||
}
|
||||
_, err := hex.DecodeString(value)
|
||||
return err == nil && strings.ToLower(value) == value
|
||||
}
|
||||
|
||||
func digest(content []byte) string {
|
||||
sum := sha256.Sum256(content)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func sameOrigin(base, target *url.URL) bool {
|
||||
if target == nil || target.User != nil || target.Host == "" {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(base.Scheme, target.Scheme) && strings.EqualFold(base.Hostname(), target.Hostname()) && effectivePort(base) == effectivePort(target)
|
||||
}
|
||||
|
||||
func effectivePort(parsed *url.URL) string {
|
||||
if port := parsed.Port(); port != "" {
|
||||
return port
|
||||
}
|
||||
if strings.EqualFold(parsed.Scheme, "https") {
|
||||
return "443"
|
||||
}
|
||||
return "80"
|
||||
}
|
||||
|
||||
func isRedirect(status int) bool {
|
||||
switch status {
|
||||
case http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther, http.StatusTemporaryRedirect, http.StatusPermanentRedirect:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user