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 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 { 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 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 }