Files
ReleaseKit/internal/validation/tree.go

342 lines
11 KiB
Go

package validation
import (
"context"
"fmt"
"path"
"path/filepath"
"sort"
"strings"
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
)
// CandidateTree is an inventory of one captured commit with blob contents
// loaded lazily by object ID.
type CandidateTree struct {
context context.Context
repositoryDir string
modulePath string
entries []git.TreeEntry
inputEntries []git.TreeEntry
byPath map[string]int
directories map[string]struct{}
blobReader *git.BlobReader
blobReads uint64
symlinkTargets map[string]string
closed bool
closeErr error
}
// TreeScope selects the automatic input files for one physical module and
// optional repository-shared files. ModuleDir is an absolute path. A nil
// SharedFiles slice selects the optional default files; a non-nil slice
// replaces those defaults.
type TreeScope struct {
ModuleDir string
SharedFiles []string
}
// ReadCandidateTree enumerates the captured commit without reading from the
// worktree. The supplied context also governs the scoped reader through closure.
func ReadCandidateTree(ctx context.Context, repositoryDir, commit string) (*CandidateTree, error) {
repositoryAbs, err := filepath.Abs(repositoryDir)
if err != nil {
return nil, fmt.Errorf("resolve candidate repository directory: %w", err)
}
return ReadCandidateTreeForScope(ctx, repositoryDir, commit, TreeScope{ModuleDir: repositoryAbs})
}
// ReadCandidateTreeForScope retains the complete committed tree for reference
// lookup and separately constructs the selected automatic input paths.
func ReadCandidateTreeForScope(ctx context.Context, repositoryDir, commit string, scope TreeScope) (*CandidateTree, error) {
entries, err := git.ReadTree(ctx, repositoryDir, commit)
if err != nil {
return nil, err
}
modulePath, err := repositoryModulePath(repositoryDir, scope.ModuleDir)
if err != nil {
return nil, err
}
tree := &CandidateTree{
context: ctx,
repositoryDir: repositoryDir,
modulePath: modulePath,
entries: entries,
byPath: make(map[string]int, len(entries)),
directories: make(map[string]struct{}),
symlinkTargets: make(map[string]string),
}
tree.directories["."] = struct{}{}
for index, entry := range entries {
tree.byPath[entry.Path] = index
for directory := path.Dir(entry.Path); directory != "."; directory = path.Dir(directory) {
tree.directories[directory] = struct{}{}
}
}
tree.inputEntries, err = selectInputEntries(entries, tree.byPath, modulePath, scope.SharedFiles)
if err != nil {
_ = tree.Close()
return nil, err
}
return tree, nil
}
// Entries returns the committed tree metadata in Git's tree order.
func (tree *CandidateTree) Entries() []git.TreeEntry {
return append([]git.TreeEntry(nil), tree.entries...)
}
// InputEntries returns the ordered committed files selected for automatic
// checks. It is separate from Entries so reference resolution retains the full
// repository tree.
func (tree *CandidateTree) InputEntries() []git.TreeEntry {
return append([]git.TreeEntry(nil), tree.inputEntries...)
}
func selectInputEntries(entries []git.TreeEntry, byPath map[string]int, modulePath string, sharedFiles []string) ([]git.TreeEntry, error) {
boundaries := make([]string, 0)
for _, entry := range entries {
moduleRelative, ok := relativeToModule(entry.Path, modulePath)
if !ok || moduleRelative == "go.mod" || path.Base(moduleRelative) != "go.mod" {
continue
}
if insideVendor(moduleRelative) {
continue
}
boundaries = append(boundaries, path.Dir(entry.Path))
}
selected := make(map[string]git.TreeEntry)
for _, entry := range entries {
moduleRelative, ok := relativeToModule(entry.Path, modulePath)
if !ok || moduleRelative == "" {
continue
}
excluded := false
for _, boundary := range boundaries {
if entry.Path == boundary || strings.HasPrefix(entry.Path, boundary+"/") {
excluded = true
break
}
}
if !excluded {
selected[entry.Path] = entry
}
}
usingDefaults := sharedFiles == nil
if usingDefaults {
sharedFiles = []string{"COPYRIGHT", "LICENSE", "docs/oss/attribution.md"}
}
for _, requested := range sharedFiles {
name, err := normalizeSharedFile(requested)
if err != nil {
cause := fmt.Errorf("invalid shared file path %q: %w", requested, err)
return nil, &Failure{Check: SharedFiles, Findings: []Finding{{Path: requested, Message: cause.Error()}}, Cause: cause}
}
index, exists := byPath[name]
if !exists || entries[index].Type != "blob" {
if usingDefaults {
continue
}
cause := fmt.Errorf("selected shared file %q is not present in the committed tree", name)
return nil, &Failure{Check: SharedFiles, Findings: []Finding{{Path: name, Message: "selected shared file is not present in the committed tree"}}, Cause: cause}
}
selected[name] = entries[index]
}
paths := make([]string, 0, len(selected))
for name := range selected {
paths = append(paths, name)
}
sort.Strings(paths)
inputs := make([]git.TreeEntry, 0, len(paths))
for _, name := range paths {
inputs = append(inputs, selected[name])
}
return inputs, nil
}
func repositoryModulePath(repositoryDir, moduleDir string) (string, error) {
repositoryAbs, err := filepath.Abs(repositoryDir)
if err != nil {
return "", fmt.Errorf("resolve candidate repository directory: %w", err)
}
if moduleDir == "" {
moduleDir = repositoryAbs
}
if !filepath.IsAbs(moduleDir) {
return "", fmt.Errorf("selected module directory must be absolute: %q", moduleDir)
}
moduleAbs, err := filepath.Abs(moduleDir)
if err != nil {
return "", fmt.Errorf("resolve selected module directory: %w", err)
}
relative, err := filepath.Rel(repositoryAbs, moduleAbs)
if err != nil {
return "", fmt.Errorf("make selected module relative to repository: %w", err)
}
if relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) {
return "", fmt.Errorf("selected module directory %q is outside repository %q", moduleDir, repositoryDir)
}
modulePath := filepath.ToSlash(relative)
if relative == "." {
modulePath = "."
}
return modulePath, nil
}
func relativeToModule(repositoryPath, modulePath string) (string, bool) {
if modulePath == "." {
return repositoryPath, true
}
if repositoryPath == modulePath {
return "", true
}
prefix := modulePath + "/"
if !strings.HasPrefix(repositoryPath, prefix) {
return "", false
}
return strings.TrimPrefix(repositoryPath, prefix), true
}
func insideVendor(moduleRelative string) bool {
for _, component := range strings.Split(moduleRelative, "/") {
if component == "vendor" {
return true
}
}
return false
}
func normalizeSharedFile(name string) (string, error) {
if name == "" || path.IsAbs(name) {
return "", fmt.Errorf("shared file path must be a repository-relative file path")
}
clean := path.Clean(name)
if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") {
return "", fmt.Errorf("shared file path must stay within the repository")
}
return clean, nil
}
// ReadFile returns newly allocated, caller-owned bytes for a committed blob.
// Regular-file content is not cached, so repeated reads request the object
// again through the tree's scoped blob reader.
func (tree *CandidateTree) ReadFile(ctx context.Context, path string) ([]byte, error) {
if tree.closed {
return nil, fmt.Errorf("read committed path %q: candidate tree is closed", path)
}
index, ok := tree.byPath[path]
if !ok {
return nil, fmt.Errorf("committed path %q does not exist", path)
}
entry := tree.entries[index]
if entry.Type != "blob" {
return nil, fmt.Errorf("committed path %q is a %s entry, not a blob", path, entry.Type)
}
if entry.Mode == "120000" {
target, err := tree.readSymlinkTarget(ctx, entry)
if err != nil {
return nil, err
}
return []byte(target), nil
}
return tree.readBlob(ctx, entry)
}
func (tree *CandidateTree) readSymlinkTarget(ctx context.Context, entry git.TreeEntry) (string, error) {
if tree.closed {
return "", fmt.Errorf("read committed path %q: candidate tree is closed", entry.Path)
}
if entry.Type != "blob" || entry.Mode != "120000" {
return "", fmt.Errorf("committed path %q is not a symlink blob", entry.Path)
}
if target, ok := tree.symlinkTargets[entry.ObjectID]; ok {
return target, nil
}
contents, err := tree.readBlob(ctx, entry)
if err != nil {
return "", err
}
target := string(contents)
if tree.symlinkTargets == nil {
tree.symlinkTargets = make(map[string]string)
}
tree.symlinkTargets[entry.ObjectID] = target
return target, nil
}
func (tree *CandidateTree) readBlob(ctx context.Context, entry git.TreeEntry) ([]byte, error) {
if tree.closed {
return nil, fmt.Errorf("read committed path %q: candidate tree is closed", entry.Path)
}
if tree.blobReader == nil {
// The operation context governs shutdown independently of each read.
operationContext := tree.context
if operationContext == nil {
operationContext = context.Background()
}
tree.blobReader = git.NewBlobReader(operationContext, tree.repositoryDir)
}
tree.blobReads++
contents, err := tree.blobReader.Read(ctx, entry.ObjectID)
if err != nil {
return nil, fmt.Errorf("read committed path %q: %w", entry.Path, err)
}
return contents, nil
}
// Close closes the scoped local Git blob reader, if content was requested.
// Metadata-only trees have no child process to close.
func (tree *CandidateTree) Close() error {
if tree.closed {
return tree.closeErr
}
tree.closed = true
if tree.blobReader != nil {
tree.closeErr = tree.blobReader.Close()
}
return tree.closeErr
}
// Entry returns the committed metadata for path when present.
func (tree *CandidateTree) Entry(path string) (git.TreeEntry, bool) {
index, ok := tree.byPath[path]
if !ok {
return git.TreeEntry{}, false
}
return tree.entries[index], true
}
// DestinationKind classifies a committed local destination.
type DestinationKind = git.DestinationKind
const (
DestinationEntry = git.DestinationEntry
DestinationDirectory = git.DestinationDirectory
DestinationMissing = git.DestinationMissing
DestinationOutsideCheckout = git.DestinationOutsideCheckout
DestinationSubmodule = git.DestinationSubmodule
DestinationSymlinkCycle = git.DestinationSymlinkCycle
)
type Destination = git.Destination
// ResolveDestination uses shared committed reference traversal with the tree's
// indexed metadata and scoped symlink reader.
func (tree *CandidateTree) ResolveDestination(ctx context.Context, sourcePath, destination string) (Destination, error) {
resolver := git.PathResolver{
RepositoryDir: tree.repositoryDir,
Lookup: func(_ context.Context, name string) (git.TreeEntry, bool, error) {
if entry, exists := tree.Entry(name); exists {
return entry, true, nil
}
_, exists := tree.directories[name]
return git.TreeEntry{Path: name, Type: "tree"}, exists, nil
},
ReadLink: tree.readSymlinkTarget,
}
return resolver.ResolveDestination(ctx, sourcePath, destination)
}