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[:]) }