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 }