142 lines
4.8 KiB
Go
142 lines
4.8 KiB
Go
|
|
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[:])
|
||
|
|
}
|