// Package validation owns the shared checks run against a captured candidate. package validation import ( "bytes" "context" "fmt" "go/format" "io" "os" "path" "strings" "gitea.maximumdirect.net/eric/ReleaseKit/internal/gotool" ) // Settings selects shared validation behavior. type Settings struct { Race bool FullVet bool CompileTargets []Target GoFormat bool Markdown *MarkdownSettings ModuleDistribution ModuleDistributionSettings FileSize *FileSizeSettings } // MarkdownSettings selects committed Markdown destination and fragment checks. type MarkdownSettings struct { EnforceCheckoutBoundary bool } // FileSizeSettings selects a per-blob limit and literal path exclusions. type FileSizeSettings struct { LimitBytes *int64 Exclusions []string } // ModuleDistributionSettings selects candidate distribution checks. type ModuleDistributionSettings struct { WorkspaceFiles bool LocalReplacements bool } // Target selects one package-compilation target. type Target struct { OS string Arch string } // Request identifies the captured source and output used by shared validation. type Request struct { RepositoryDir string Commit string Output io.Writer Settings Settings } // ReadCandidateTree loads the committed-file inventory when a selected check // needs file-oriented input. func (request Request) ReadCandidateTree(ctx context.Context) (*CandidateTree, error) { return ReadCandidateTree(ctx, request.RepositoryDir, request.Commit) } // Run executes shared validation for the captured candidate. func Run(ctx context.Context, request Request) (result error) { if request.Settings.FileSize != nil && request.Settings.FileSize.LimitBytes != nil && *request.Settings.FileSize.LimitBytes < 0 { return &Failure{ Check: FileSize, Findings: []Finding{{Message: "file size limit must not be negative"}}, } } for _, target := range request.Settings.CompileTargets { if strings.TrimSpace(target.OS) == "" || strings.TrimSpace(target.Arch) == "" || strings.Contains(target.OS, "/") || strings.Contains(target.Arch, "/") { return &Failure{ Check: PackageCompile, Findings: []Finding{{ Target: target.OS + "/" + target.Arch, Message: "target must be one complete GOOS/GOARCH pair", }}, } } } tool, err := gotool.New(ctx, request.RepositoryDir) if err != nil { return &Failure{Check: GoTest, Cause: err} } args := []string{"test", "-mod=readonly", "-count=1"} if request.Settings.Race { args = append(args, "-race") } if request.Settings.FullVet { args = append(args, "-vet=off") } args = append(args, "./...") err = tool.Run(ctx, request.RepositoryDir, gotool.Native, gotool.Target{}, request.Output, args...) if err != nil { return &Failure{Check: GoTest, Cause: err} } if request.Settings.FullVet { err = tool.Run(ctx, request.RepositoryDir, gotool.Native, gotool.Target{}, request.Output, "vet", "-mod=readonly", "./...") if err != nil { return &Failure{Check: GoVet, Cause: err} } } for _, target := range request.Settings.CompileTargets { selected := gotool.Target{OS: target.OS, Arch: target.Arch} err = tool.Run(ctx, request.RepositoryDir, gotool.Static, selected, request.Output, "build", "-mod=readonly", "-o", os.DevNull, "./...") if err != nil { return &Failure{ Check: PackageCompile, Findings: []Finding{{ Target: fmt.Sprintf("%s/%s", target.OS, target.Arch), Message: "package compilation failed", }}, Cause: err, } } } if request.Settings.GoFormat || request.Settings.ModuleDistribution.WorkspaceFiles || request.Settings.ModuleDistribution.LocalReplacements || request.Settings.FileSize != nil || request.Settings.Markdown != nil { tree, err := request.ReadCandidateTree(ctx) if err != nil { check := MarkdownLinks if request.Settings.FileSize != nil { check = FileSize } if request.Settings.ModuleDistribution.LocalReplacements { check = LocalReplacements } if request.Settings.ModuleDistribution.WorkspaceFiles { check = WorkspaceFiles } if request.Settings.GoFormat { check = GoFormat } return &Failure{Check: check, Cause: err} } lastContentCheck := Check("") defer func() { if closeErr := tree.Close(); closeErr != nil && result == nil && lastContentCheck != "" { result = &Failure{ Check: lastContentCheck, Cause: fmt.Errorf("close candidate Git blob reader: %w", closeErr), } } }() if request.Settings.GoFormat { readsBefore := tree.blobReads if err := checkGoFormat(ctx, tree); err != nil { return err } if tree.blobReads != readsBefore { lastContentCheck = GoFormat } } if request.Settings.ModuleDistribution.WorkspaceFiles { if err := checkWorkspaceFiles(tree); err != nil { return err } } if request.Settings.ModuleDistribution.LocalReplacements { readsBefore := tree.blobReads if err := checkLocalReplacements(ctx, tree); err != nil { return err } if tree.blobReads != readsBefore { lastContentCheck = LocalReplacements } } if request.Settings.FileSize != nil { if err := checkFileSize(tree, request.Settings.FileSize); err != nil { return err } } if request.Settings.Markdown != nil { readsBefore := tree.blobReads if err := checkMarkdownLinks(ctx, tree, request.Settings.Markdown.EnforceCheckoutBoundary, request.Output); err != nil { return err } if tree.blobReads != readsBefore { lastContentCheck = MarkdownLinks } } } return nil } func checkWorkspaceFiles(tree *CandidateTree) error { var findings []Finding for _, entry := range tree.Entries() { if entry.Type != "blob" { continue } switch path.Base(entry.Path) { case "go.work", "go.work.sum": findings = append(findings, Finding{ Path: entry.Path, Message: "workspace file is committed in the candidate", }) } } if len(findings) != 0 { return &Failure{Check: WorkspaceFiles, Findings: findings} } return nil } func checkGoFormat(ctx context.Context, tree *CandidateTree) error { var findings []Finding formattedByObjectID := make(map[string]bool) for _, entry := range tree.Entries() { if entry.Type != "blob" || (entry.Mode != "100644" && entry.Mode != "100755") || !strings.HasSuffix(entry.Path, ".go") { continue } if err := ctx.Err(); err != nil { return &Failure{Check: GoFormat, Cause: err} } if formatted, ok := formattedByObjectID[entry.ObjectID]; ok { if !formatted { findings = append(findings, Finding{Path: entry.Path, Message: "file is not gofmt-formatted"}) } continue } contents, err := tree.ReadFile(ctx, entry.Path) if err != nil { return &Failure{ Check: GoFormat, Findings: []Finding{{ Path: entry.Path, Message: "unable to inspect committed Go source", }}, Cause: fmt.Errorf("inspect committed Go file %q: %w", entry.Path, err), } } formatted, err := format.Source(contents) if err != nil { return &Failure{ Check: GoFormat, Findings: []Finding{{ Path: entry.Path, Message: "committed Go source cannot be formatted", }}, Cause: fmt.Errorf("format committed Go file %q: %w", entry.Path, err), } } isFormatted := bytes.Equal(contents, formatted) formattedByObjectID[entry.ObjectID] = isFormatted contents = nil formatted = nil if !isFormatted { findings = append(findings, Finding{Path: entry.Path, Message: "file is not gofmt-formatted"}) } } if len(findings) != 0 { return &Failure{Check: GoFormat, Findings: findings} } return nil }