Files
ReleaseKit/internal/git/path.go

156 lines
5.2 KiB
Go

package git
import (
"context"
"fmt"
"path"
"path/filepath"
"strings"
)
// PathResolver traverses committed paths, sharing reference rules between
// release identity and validation. Lookups include both files and directories.
type PathResolver struct {
RepositoryDir string
Lookup func(context.Context, string) (TreeEntry, bool, error)
ReadLink func(context.Context, TreeEntry) (string, error)
}
// 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 (resolver PathResolver) 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 := resolver.relativeAbsoluteDestination(destination)
if err != nil {
return Destination{}, err
}
if !inside {
return Destination{Kind: DestinationOutsideCheckout, Path: destination}, nil
}
directory, destination = ".", relative
}
return resolver.resolveDestination(ctx, directory, strings.Split(destination, "/"), make(map[string]bool))
}
func (resolver PathResolver) 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, err := resolver.Lookup(ctx, partial)
if err != nil {
return Destination{}, err
}
if !exists {
return Destination{Kind: DestinationMissing, Path: unresolvedPath(partial, remaining)}, nil
}
if entry.Type == "tree" {
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
}
linkText, err := resolver.ReadLink(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 := resolver.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 := resolver.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 resolver.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 (resolver PathResolver) relativeAbsoluteDestination(destination string) (string, bool, error) {
root, err := filepath.Abs(resolver.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, "/")
}