438 lines
14 KiB
Go
438 lines
14 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
|
|
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
|
|
}
|
|
tree := &CandidateTree{
|
|
context: ctx,
|
|
repositoryDir: repositoryDir,
|
|
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(repositoryDir, entries, tree.byPath, scope)
|
|
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(repositoryDir string, entries []git.TreeEntry, byPath map[string]int, scope TreeScope) ([]git.TreeEntry, error) {
|
|
repositoryAbs, err := filepath.Abs(repositoryDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve candidate repository directory: %w", err)
|
|
}
|
|
moduleDir := scope.ModuleDir
|
|
if moduleDir == "" {
|
|
moduleDir = repositoryAbs
|
|
}
|
|
if !filepath.IsAbs(moduleDir) {
|
|
return nil, fmt.Errorf("selected module directory must be absolute: %q", moduleDir)
|
|
}
|
|
moduleAbs, err := filepath.Abs(moduleDir)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("resolve selected module directory: %w", err)
|
|
}
|
|
relative, err := filepath.Rel(repositoryAbs, moduleAbs)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("make selected module relative to repository: %w", err)
|
|
}
|
|
if relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) {
|
|
return nil, fmt.Errorf("selected module directory %q is outside repository %q", moduleDir, repositoryDir)
|
|
}
|
|
modulePath := filepath.ToSlash(relative)
|
|
if relative == "." {
|
|
modulePath = "."
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
sharedFiles := scope.SharedFiles
|
|
usingDefaults := sharedFiles == nil
|
|
if usingDefaults {
|
|
sharedFiles = []string{"COPYRIGHT", "LICENSE", "docs/oss/attribution.md"}
|
|
}
|
|
for _, requested := range sharedFiles {
|
|
name, err := normalizeSharedFile(requested)
|
|
if err != nil {
|
|
return nil, &Failure{Check: SharedFiles, Findings: []Finding{{Path: requested, Message: err.Error()}}}
|
|
}
|
|
index, exists := byPath[name]
|
|
if !exists || entries[index].Type != "blob" {
|
|
if usingDefaults {
|
|
continue
|
|
}
|
|
return nil, &Failure{Check: SharedFiles, Findings: []Finding{{Path: name, Message: "selected shared file is not present in the committed tree"}}}
|
|
}
|
|
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 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) || strings.Contains(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 string
|
|
|
|
const (
|
|
DestinationEntry DestinationKind = "entry"
|
|
DestinationDirectory DestinationKind = "directory"
|
|
DestinationMissing DestinationKind = "missing"
|
|
DestinationOutsideCheckout DestinationKind = "outside-checkout"
|
|
DestinationSubmodule DestinationKind = "submodule"
|
|
DestinationSymlinkCycle DestinationKind = "symlink-cycle"
|
|
)
|
|
|
|
// Destination records the resolved identity and its classification.
|
|
type Destination struct {
|
|
Kind DestinationKind
|
|
Path string
|
|
}
|
|
|
|
// ResolveDestination resolves local destinations using the captured Git tree.
|
|
// Relative destinations start at the source directory; absolute destinations
|
|
// within the repository directory map back to committed paths.
|
|
func (tree *CandidateTree) ResolveDestination(ctx context.Context, sourcePath, destination string) (Destination, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return Destination{}, err
|
|
}
|
|
directory := path.Dir(sourcePath)
|
|
if path.IsAbs(destination) {
|
|
relative, inside, err := tree.relativeAbsoluteDestination(destination)
|
|
if err != nil {
|
|
return Destination{}, err
|
|
}
|
|
if !inside {
|
|
return Destination{Kind: DestinationOutsideCheckout, Path: destination}, nil
|
|
}
|
|
directory, destination = ".", relative
|
|
}
|
|
return tree.resolveDestination(ctx, directory, strings.Split(destination, "/"), make(map[string]bool))
|
|
}
|
|
|
|
func (tree *CandidateTree) resolveDestination(ctx context.Context, directory string, components []string, active map[string]bool) (Destination, error) {
|
|
for index, component := range components {
|
|
if err := ctx.Err(); err != nil {
|
|
return Destination{}, err
|
|
}
|
|
remaining := components[index+1:]
|
|
switch component {
|
|
case "", ".":
|
|
continue
|
|
case "..":
|
|
if directory == "." {
|
|
return Destination{Kind: DestinationOutsideCheckout, Path: unresolvedPath("..", remaining)}, nil
|
|
}
|
|
directory = path.Dir(directory)
|
|
continue
|
|
}
|
|
partial := path.Join(directory, component)
|
|
entry, exists := tree.Entry(partial)
|
|
if !exists {
|
|
if _, isDirectory := tree.directories[partial]; !isDirectory {
|
|
return Destination{Kind: DestinationMissing, Path: unresolvedPath(partial, remaining)}, nil
|
|
}
|
|
directory = partial
|
|
continue
|
|
}
|
|
if entry.Mode == "160000" {
|
|
return Destination{Kind: DestinationSubmodule, Path: unresolvedPath(partial, remaining)}, nil
|
|
}
|
|
if entry.Mode == "120000" {
|
|
if active[partial] {
|
|
return Destination{Kind: DestinationSymlinkCycle, Path: partial}, nil
|
|
}
|
|
entry, _ := tree.Entry(partial)
|
|
linkText, err := tree.readSymlinkTarget(ctx, entry)
|
|
if err != nil {
|
|
return Destination{}, fmt.Errorf("read committed symlink %q: %w", partial, err)
|
|
}
|
|
target := linkText
|
|
targetDirectory := directory
|
|
if path.IsAbs(target) {
|
|
relative, inside, err := tree.relativeAbsoluteDestination(target)
|
|
if err != nil {
|
|
return Destination{}, err
|
|
}
|
|
if !inside {
|
|
return Destination{Kind: DestinationOutsideCheckout, Path: unresolvedPath(target, remaining)}, nil
|
|
}
|
|
targetDirectory, target = ".", relative
|
|
}
|
|
active[partial] = true
|
|
destination, err := tree.resolveDestination(ctx, targetDirectory, strings.Split(target, "/"), active)
|
|
delete(active, partial)
|
|
if err != nil || len(remaining) == 0 {
|
|
return destination, err
|
|
}
|
|
switch destination.Kind {
|
|
case DestinationDirectory:
|
|
return tree.resolveDestination(ctx, destination.Path, remaining, active)
|
|
case DestinationEntry:
|
|
destination.Kind = DestinationMissing
|
|
case DestinationSymlinkCycle:
|
|
return destination, nil
|
|
}
|
|
destination.Path = unresolvedPath(destination.Path, remaining)
|
|
return destination, nil
|
|
}
|
|
if len(remaining) != 0 {
|
|
return Destination{Kind: DestinationMissing, Path: unresolvedPath(partial, remaining)}, nil
|
|
}
|
|
return Destination{Kind: DestinationEntry, Path: partial}, nil
|
|
}
|
|
return Destination{Kind: DestinationDirectory, Path: directory}, nil
|
|
}
|
|
|
|
func (tree *CandidateTree) relativeAbsoluteDestination(destination string) (string, bool, error) {
|
|
root, err := filepath.Abs(tree.repositoryDir)
|
|
if err != nil {
|
|
return "", false, fmt.Errorf("resolve candidate repository directory: %w", err)
|
|
}
|
|
root = filepath.ToSlash(root)
|
|
if destination == root {
|
|
return ".", true, nil
|
|
}
|
|
// Preserve the suffix for component traversal, including symlinks before '..'.
|
|
relative, inside := strings.CutPrefix(destination, strings.TrimSuffix(root, "/")+"/")
|
|
return relative, inside, nil
|
|
}
|
|
|
|
// unresolvedPath preserves components that could not be traversed. Cleaning
|
|
// them would hide missing entries, non-directories, or checkout boundaries.
|
|
func unresolvedPath(base string, remaining []string) string {
|
|
if len(remaining) == 0 {
|
|
return base
|
|
}
|
|
return base + "/" + strings.Join(remaining, "/")
|
|
}
|