278 lines
9.4 KiB
Go
278 lines
9.4 KiB
Go
package cli
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
|
|
"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 == "" || !artifact.ValidComponent(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))
|
|
targets := make([]artifact.Target, 0, len(receipt.Targets))
|
|
for _, target := range receipt.Targets {
|
|
if !artifact.ValidComponent(target.OS) || !artifact.ValidComponent(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
|
|
targets = append(targets, artifact.Target{OS: target.OS, Arch: target.Arch})
|
|
}
|
|
declarations := artifact.ExpectedBuild(receipt.BinaryName, receipt.Version, targets)
|
|
wantByTarget := make(map[ReceiptTarget]string, len(declarations))
|
|
for _, declaration := range declarations {
|
|
target := ReceiptTarget{OS: declaration.Target.OS, Arch: declaration.Target.Arch}
|
|
wantByTarget[target] = declaration.Filename
|
|
}
|
|
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 := wantByTarget[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 {
|
|
parsed := make([]artifact.Target, 0, len(targets))
|
|
for _, value := range targets {
|
|
goos, goarch, ok := strings.Cut(value, "/")
|
|
if !ok {
|
|
continue
|
|
}
|
|
parsed = append(parsed, artifact.Target{OS: goos, Arch: goarch})
|
|
}
|
|
normalized := artifact.NormalizeTargets(parsed)
|
|
result := make([]ReceiptTarget, 0, len(normalized))
|
|
for _, target := range normalized {
|
|
result = append(result, ReceiptTarget{OS: target.OS, Arch: target.Arch})
|
|
}
|
|
return result
|
|
}
|
|
|
|
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
|
|
}
|