Files
ReleaseKit/cmd/releasekit/internal/cli/receipt.go

281 lines
9.1 KiB
Go

package cli
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
)
const BuildReceiptName = ".releasekit-build.json"
type ReceiptTarget struct {
OS string `json:"os"`
Arch string `json:"arch"`
}
type ReceiptArtifact struct {
Path string `json:"path"`
Filename string `json:"filename"`
Target ReceiptTarget `json:"target"`
SHA256 string `json:"sha256"`
}
// BuildReceipt is private local coordination data for generated binaries.
type BuildReceipt struct {
RepositoryDir string `json:"repository_dir"`
CandidateCommit string `json:"candidate_commit"`
SourceVerified bool `json:"source_verified"`
Version string `json:"version"`
MainPackage string `json:"main_package"`
BinaryName string `json:"binary_name"`
OutputDir string `json:"output_dir"`
Targets []ReceiptTarget `json:"targets"`
Compiler string `json:"compiler"`
Artifacts []ReceiptArtifact `json:"artifacts"`
}
func WriteBuildReceipt(outputDir string, receipt BuildReceipt) (string, error) {
resolvedDir, err := filepath.Abs(outputDir)
if err != nil {
return "", fmt.Errorf("resolve build receipt directory: %w", err)
}
receipt.OutputDir = resolvedDir
if err := validateBuildReceipt(receipt, true); err != nil {
return "", err
}
contents, err := json.MarshalIndent(receipt, "", " ")
if err != nil {
return "", fmt.Errorf("encode build receipt: %w", err)
}
contents = append(contents, '\n')
temporary, err := os.CreateTemp(resolvedDir, ".releasekit-build-receipt-*")
if err != nil {
return "", fmt.Errorf("create temporary build receipt: %w", err)
}
temporaryPath := temporary.Name()
defer os.Remove(temporaryPath)
if _, err := temporary.Write(contents); err != nil {
_ = temporary.Close()
return "", fmt.Errorf("write build receipt: %w", err)
}
if err := temporary.Close(); err != nil {
return "", fmt.Errorf("close build receipt: %w", err)
}
path := filepath.Join(resolvedDir, BuildReceiptName)
if err := os.Rename(temporaryPath, path); err != nil {
if _, statErr := os.Lstat(path); statErr != nil {
return "", fmt.Errorf("install build receipt: %w", err)
}
if removeErr := os.Remove(path); removeErr != nil {
return "", fmt.Errorf("replace prior build receipt: %w", removeErr)
}
if err := os.Rename(temporaryPath, path); err != nil {
return "", fmt.Errorf("install build receipt: %w", err)
}
}
return path, nil
}
func LoadBuildReceipt(path string) (BuildReceipt, error) {
resolvedPath, err := filepath.Abs(path)
if err != nil {
return BuildReceipt{}, fmt.Errorf("resolve build receipt path: %w", err)
}
if filepath.Base(resolvedPath) != BuildReceiptName {
return BuildReceipt{}, fmt.Errorf("build receipt filename must be %s", BuildReceiptName)
}
file, err := os.Open(resolvedPath)
if err != nil {
return BuildReceipt{}, fmt.Errorf("open build receipt: %w", err)
}
defer file.Close()
var receipt BuildReceipt
decoder := json.NewDecoder(file)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&receipt); err != nil {
return BuildReceipt{}, fmt.Errorf("decode build receipt: %w", err)
}
var extra any
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
if err == nil {
return BuildReceipt{}, errors.New("decode build receipt: multiple JSON values")
}
return BuildReceipt{}, fmt.Errorf("decode build receipt: %w", err)
}
if err := validateBuildReceipt(receipt, true); err != nil {
return BuildReceipt{}, err
}
if filepath.Clean(receipt.OutputDir) != filepath.Dir(resolvedPath) {
return BuildReceipt{}, errors.New("build receipt output directory does not match its location")
}
return receipt, nil
}
func (receipt BuildReceipt) MatchesBuild(repositoryDir, commit, version string, settings BuildSettings) bool {
resolvedRepo, err := filepath.Abs(repositoryDir)
if err != nil || filepath.Clean(receipt.RepositoryDir) != filepath.Clean(resolvedRepo) {
return false
}
if receipt.CandidateCommit != commit || receipt.Version != version || receipt.MainPackage != settings.MainPackage || receipt.BinaryName != settings.BinaryName {
return false
}
want := normalizeReceiptTargets(settings.Targets)
if len(want) != len(receipt.Targets) {
return false
}
for i, target := range want {
if target != receipt.Targets[i] {
return false
}
}
return true
}
func validateBuildReceipt(receipt BuildReceipt, verifyFiles bool) error {
if !versioncontract.ValidStableVersion(receipt.Version) {
return fmt.Errorf("build receipt has invalid version %q", receipt.Version)
}
if !filepath.IsAbs(receipt.RepositoryDir) || !filepath.IsAbs(receipt.OutputDir) {
return errors.New("build receipt repository and output directories must be absolute")
}
if !validCommitID(receipt.CandidateCommit) {
return errors.New("build receipt has invalid candidate commit")
}
if receipt.MainPackage == "" || !safeReceiptComponent(receipt.BinaryName) || receipt.Compiler == "" {
return errors.New("build receipt is missing build identity")
}
if len(receipt.Targets) == 0 || len(receipt.Artifacts) != len(receipt.Targets) {
return errors.New("build receipt target and artifact counts do not match")
}
seenTargets := make(map[ReceiptTarget]bool, len(receipt.Targets))
seenArtifacts := make(map[ReceiptTarget]bool, len(receipt.Artifacts))
for _, target := range receipt.Targets {
if !safeReceiptComponent(target.OS) || !safeReceiptComponent(target.Arch) {
return fmt.Errorf("build receipt has unsafe target %q/%q", target.OS, target.Arch)
}
if seenTargets[target] {
return fmt.Errorf("build receipt repeats target %s/%s", target.OS, target.Arch)
}
seenTargets[target] = true
}
for _, artifact := range receipt.Artifacts {
if !seenTargets[artifact.Target] || seenArtifacts[artifact.Target] {
return fmt.Errorf("build receipt has an unexpected artifact target %s/%s", artifact.Target.OS, artifact.Target.Arch)
}
seenArtifacts[artifact.Target] = true
wantName := receiptArtifactFilename(receipt.BinaryName, receipt.Version, artifact.Target)
if artifact.Filename != wantName || filepath.Base(artifact.Filename) != artifact.Filename {
return fmt.Errorf("build receipt has invalid artifact filename %q", artifact.Filename)
}
wantPath := filepath.Join(receipt.OutputDir, artifact.Filename)
if !filepath.IsAbs(artifact.Path) || filepath.Clean(artifact.Path) != filepath.Clean(wantPath) {
return fmt.Errorf("build receipt artifact path does not match filename %q", artifact.Filename)
}
if !validSHA256(artifact.SHA256) {
return fmt.Errorf("build receipt has invalid SHA-256 for %q", artifact.Filename)
}
if verifyFiles {
if err := verifyReceiptArtifact(artifact); err != nil {
return err
}
}
}
return nil
}
func verifyReceiptArtifact(artifact ReceiptArtifact) error {
info, err := os.Lstat(artifact.Path)
if err != nil {
return fmt.Errorf("inspect build artifact %q: %w", artifact.Path, err)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("build artifact %q is not a regular file", artifact.Path)
}
file, err := os.Open(artifact.Path)
if err != nil {
return fmt.Errorf("open build artifact %q: %w", artifact.Path, err)
}
defer file.Close()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return fmt.Errorf("hash build artifact %q: %w", artifact.Path, err)
}
if actual := hex.EncodeToString(hash.Sum(nil)); actual != artifact.SHA256 {
return fmt.Errorf("build artifact %q hash changed: got %s, expected %s", artifact.Path, actual, artifact.SHA256)
}
return nil
}
func normalizeReceiptTargets(targets []string) []ReceiptTarget {
seen := make(map[ReceiptTarget]bool, len(targets))
result := make([]ReceiptTarget, 0, len(targets))
for _, value := range targets {
goos, goarch, ok := strings.Cut(value, "/")
if !ok {
continue
}
target := ReceiptTarget{OS: goos, Arch: goarch}
if !seen[target] {
seen[target] = true
result = append(result, target)
}
}
return result
}
func receiptArtifactFilename(binaryName, version string, target ReceiptTarget) string {
filename := fmt.Sprintf("%s-%s-%s-%s", binaryName, version, target.OS, target.Arch)
if target.OS == "windows" {
filename += ".exe"
}
return filename
}
func safeReceiptComponent(value string) bool {
return value != "" && !strings.ContainsAny(value, "/\\\r\n")
}
func validCommitID(value string) bool {
if len(value) != 40 && len(value) != 64 {
return false
}
for _, char := range value {
if !(char >= '0' && char <= '9' || char >= 'a' && char <= 'f') {
return false
}
}
return true
}
func validSHA256(value string) bool {
if len(value) != sha256.Size*2 {
return false
}
for _, char := range value {
if !(char >= '0' && char <= '9' || char >= 'a' && char <= 'f') {
return false
}
}
return true
}
// RemoveBuildReceipt invalidates prior CLI coordination data after a rebuild.
func RemoveBuildReceipt(outputDir string) error {
err := os.Remove(filepath.Join(outputDir, BuildReceiptName))
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return fmt.Errorf("invalidate prior build receipt: %w", err)
}
return nil
}