Move asset verification into the artifact package
This commit is contained in:
141
internal/artifact/assets.go
Normal file
141
internal/artifact/assets.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package artifact
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrInvalidAsset identifies an invalid asset declaration or unreadable
|
||||
// local path.
|
||||
ErrInvalidAsset = errors.New("invalid asset")
|
||||
// ErrAssetConflict identifies local content that does not match its
|
||||
// declared identity.
|
||||
ErrAssetConflict = errors.New("asset content conflict")
|
||||
)
|
||||
|
||||
// ExpectedAsset describes the local content declared for hosted delivery.
|
||||
// Content is populated for inline assets such as the generated checksum
|
||||
// manifest; Path identifies file content for later streaming uploads.
|
||||
type ExpectedAsset struct {
|
||||
Filename string
|
||||
SHA256 string
|
||||
Size int64
|
||||
Path string
|
||||
Content []byte
|
||||
}
|
||||
|
||||
// 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{}, fmt.Errorf("%w: invalid binary identity for %q", ErrInvalidAsset, binary.Filename)
|
||||
}
|
||||
if _, exists := seen[binary.Filename]; exists {
|
||||
return ExpectedAsset{}, fmt.Errorf("%w: duplicate binary filename %q", ErrInvalidAsset, 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
|
||||
}
|
||||
|
||||
// ValidateExpectedAssets checks the declarations before they are compared
|
||||
// with remote content or uploaded.
|
||||
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 fmt.Errorf("%w: %q", ErrInvalidAsset, item.Filename)
|
||||
}
|
||||
if _, duplicate := seen[item.Filename]; duplicate {
|
||||
return fmt.Errorf("%w: duplicate expected asset %q", ErrInvalidAsset, item.Filename)
|
||||
}
|
||||
seen[item.Filename] = struct{}{}
|
||||
if len(item.Content) > 0 && (int64(len(item.Content)) != item.Size || digest(item.Content) != item.SHA256) {
|
||||
return fmt.Errorf("%w: expected content does not match identity for %q", ErrInvalidAsset, item.Filename)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyLocalAssets checks that every expected file still matches its declared
|
||||
// size and digest before hosted writes begin.
|
||||
func VerifyLocalAssets(assets []ExpectedAsset) error {
|
||||
if err := ValidateExpectedAssets(assets); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, asset := range assets {
|
||||
if err := verifyLocalAsset(asset); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifyLocalAsset(asset ExpectedAsset) error {
|
||||
if len(asset.Content) > 0 {
|
||||
if int64(len(asset.Content)) != asset.Size || digest(asset.Content) != asset.SHA256 {
|
||||
return fmt.Errorf("%w: local content does not match declared asset %q", ErrAssetConflict, asset.Filename)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if asset.Path == "" || filepath.Base(asset.Path) != asset.Filename {
|
||||
return fmt.Errorf("%w: local path for %q does not match its filename", ErrInvalidAsset, asset.Filename)
|
||||
}
|
||||
file, err := os.Open(asset.Path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: open %q: %w", ErrInvalidAsset, asset.Filename, err)
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: stat %q: %w", ErrInvalidAsset, asset.Filename, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() || info.Size() != asset.Size {
|
||||
return fmt.Errorf("%w: local file does not match declared size for %q", ErrAssetConflict, asset.Filename)
|
||||
}
|
||||
hasher := sha256.New()
|
||||
size, err := io.Copy(hasher, file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: read %q: %w", ErrInvalidAsset, asset.Filename, err)
|
||||
}
|
||||
if size != asset.Size || hex.EncodeToString(hasher.Sum(nil)) != asset.SHA256 {
|
||||
return fmt.Errorf("%w: local content does not match declared asset %q", ErrAssetConflict, asset.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[:])
|
||||
}
|
||||
Reference in New Issue
Block a user