package validation import ( "context" "fmt" "path" "path/filepath" "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 byPath map[string]int directories map[string]struct{} blobReader *git.BlobReader blobReads uint64 symlinkTargets map[string]string closed bool closeErr error } // 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) { 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{}{} } } 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...) } // 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, "/") }