306 lines
11 KiB
Go
306 lines
11 KiB
Go
package cli
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/artifact"
|
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/releaseidentity"
|
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/versioncontract"
|
|
)
|
|
|
|
const BuildReceiptName = ".releasekit-build.json"
|
|
const BuildReceiptSchemaVersion = 1
|
|
|
|
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 {
|
|
SchemaVersion int `json:"schema_version"`
|
|
RepositoryDir string `json:"repository_dir"`
|
|
ModuleDir string `json:"module_dir"`
|
|
ModulePath string `json:"module_path"`
|
|
Tag string `json:"tag"`
|
|
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); 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); err != nil {
|
|
if receipt.SchemaVersion != BuildReceiptSchemaVersion {
|
|
return BuildReceipt{}, fmt.Errorf("%w; regenerate it with the updated build command", err)
|
|
}
|
|
return BuildReceipt{}, err
|
|
}
|
|
if !sameDirectory(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 {
|
|
if !sameDirectory(receipt.RepositoryDir, repositoryDir) {
|
|
return false
|
|
}
|
|
if receipt.CandidateCommit != commit || receipt.Version != version {
|
|
return false
|
|
}
|
|
want := artifact.NormalizeSettings(settings.MainPackage, settings.BinaryName, parseReceiptTargets(settings.Targets))
|
|
got := artifact.NormalizeSettings(receipt.MainPackage, receipt.BinaryName, parseStoredTargets(receipt.Targets))
|
|
if want.MainPackage != got.MainPackage || want.BinaryName != got.BinaryName || len(want.Targets) != len(got.Targets) {
|
|
return false
|
|
}
|
|
for i, target := range want.Targets {
|
|
if target != got.Targets[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func sameDirectory(first, second string) bool {
|
|
firstInfo, err := os.Stat(first)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
secondInfo, err := os.Stat(second)
|
|
return err == nil && os.SameFile(firstInfo, secondInfo)
|
|
}
|
|
|
|
func validateBuildReceipt(receipt BuildReceipt) error {
|
|
if receipt.SchemaVersion != BuildReceiptSchemaVersion {
|
|
return fmt.Errorf("unsupported or missing build receipt schema version %d", receipt.SchemaVersion)
|
|
}
|
|
if !versioncontract.ValidStableVersion(receipt.Version) {
|
|
return fmt.Errorf("build receipt has invalid version %q", receipt.Version)
|
|
}
|
|
if !filepath.IsAbs(receipt.RepositoryDir) || !filepath.IsAbs(receipt.ModuleDir) || !filepath.IsAbs(receipt.OutputDir) {
|
|
return errors.New("build receipt repository, module, and output directories must be absolute")
|
|
}
|
|
if strings.TrimSpace(receipt.ModulePath) == "" || strings.TrimSpace(receipt.Tag) == "" {
|
|
return errors.New("build receipt is missing module path or full tag")
|
|
}
|
|
if err := validateRecordedIdentity(receipt); err != nil {
|
|
return fmt.Errorf("build receipt has inconsistent module identity: %w", err)
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateRecordedIdentity(receipt BuildReceipt) error {
|
|
repositoryDir := filepath.Clean(receipt.RepositoryDir)
|
|
moduleDir := filepath.Clean(receipt.ModuleDir)
|
|
relative, err := filepath.Rel(repositoryDir, moduleDir)
|
|
if err != nil {
|
|
return fmt.Errorf("relate module directory to repository: %w", err)
|
|
}
|
|
if relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) {
|
|
return errors.New("module directory is outside repository")
|
|
}
|
|
if relative == "" {
|
|
relative = "."
|
|
}
|
|
identity, err := releaseidentity.Derive(releaseidentity.Selection{
|
|
RepositoryDir: repositoryDir,
|
|
ModuleDir: moduleDir,
|
|
ModuleSubdir: filepath.ToSlash(relative),
|
|
}, []byte("module "+receipt.ModulePath+"\n"), receipt.Version)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if identity.Tag != receipt.Tag {
|
|
return fmt.Errorf("full tag %q does not match module selection and version; want %q", receipt.Tag, identity.Tag)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func parseReceiptTargets(targets []string) []artifact.Target {
|
|
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})
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
func parseStoredTargets(targets []ReceiptTarget) []artifact.Target {
|
|
parsed := make([]artifact.Target, 0, len(targets))
|
|
for _, target := range targets {
|
|
parsed = append(parsed, artifact.Target{OS: target.OS, Arch: target.Arch})
|
|
}
|
|
return parsed
|
|
}
|
|
|
|
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
|
|
}
|