Stop recursive symlink expansion and honor cancellation
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Shared validation foundation remediation
|
# Shared validation foundation remediation
|
||||||
|
|
||||||
Status: Findings pending remediation.
|
Status: Remediation in progress; finding 1 addressed, findings 2–6 pending.
|
||||||
|
|
||||||
Reviewed committed implementation `f53354b`, covering the eight foundation
|
Reviewed committed implementation `f53354b`, covering the eight foundation
|
||||||
commits after `0c7129a`, against the [implementation plan](implementation.md),
|
commits after `0c7129a`, against the [implementation plan](implementation.md),
|
||||||
@@ -31,6 +31,12 @@ that subsequent checkers will use.
|
|||||||
|
|
||||||
## 1. P2: Expanding symlink cycles do not terminate or honor cancellation
|
## 1. P2: Expanding symlink cycles do not terminate or honor cancellation
|
||||||
|
|
||||||
|
**Resolution:** Addressed. The resolver tracks active symlink expansions,
|
||||||
|
releases them before traversing the remaining destination, and checks context
|
||||||
|
cancellation during traversal. Regression coverage includes direct and mutual
|
||||||
|
expanding cycles, finite repeated links, and cancellation.
|
||||||
|
`go test ./internal/validation` passed.
|
||||||
|
|
||||||
**Owner:** [candidate destination resolver](../../internal/validation/tree.go),
|
**Owner:** [candidate destination resolver](../../internal/validation/tree.go),
|
||||||
`CandidateTree.ResolveDestination`, especially the restart loop and cycle key.
|
`CandidateTree.ResolveDestination`, especially the restart loop and cycle key.
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ package validation
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestResolveDestinationClassifiesCommittedPaths(t *testing.T) {
|
func TestResolveDestinationClassifiesCommittedPaths(t *testing.T) {
|
||||||
@@ -81,6 +83,65 @@ func TestResolveDestinationClassifiesCommittedPaths(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveDestinationHandlesExpandingCyclesAndRepeatedLinks(t *testing.T) {
|
||||||
|
repository := newTreeRepository(t)
|
||||||
|
writeTreeFile(t, repository, "README.md", []byte("source\n"))
|
||||||
|
for name, target := range map[string]string{
|
||||||
|
"direct": "direct/child",
|
||||||
|
"mutual-a": "mutual-b/child",
|
||||||
|
"mutual-b": "mutual-a/child",
|
||||||
|
"alias": ".",
|
||||||
|
} {
|
||||||
|
if err := os.Symlink(target, filepath.Join(repository, name)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gitTree(t, repository, "add", "--all")
|
||||||
|
gitTree(t, repository, "commit", "-m", "capture recursive links")
|
||||||
|
tree, err := ReadCandidateTree(t.Context(), repository, strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD")))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
destination string
|
||||||
|
kind DestinationKind
|
||||||
|
}{
|
||||||
|
{"direct", DestinationSymlinkCycle},
|
||||||
|
{"mutual-a", DestinationSymlinkCycle},
|
||||||
|
{"alias/alias/README.md", DestinationEntry},
|
||||||
|
} {
|
||||||
|
t.Run(test.destination, func(t *testing.T) {
|
||||||
|
type result struct {
|
||||||
|
destination Destination
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
done := make(chan result, 1)
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
go func() {
|
||||||
|
destination, err := tree.ResolveDestination(ctx, "README.md", test.destination)
|
||||||
|
done <- result{destination, err}
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case got := <-done:
|
||||||
|
if got.err != nil || got.destination.Kind != test.kind {
|
||||||
|
t.Fatalf("resolution = %#v, %v; want %s", got.destination, got.err, test.kind)
|
||||||
|
}
|
||||||
|
if test.kind == DestinationEntry && got.destination.Path != "README.md" {
|
||||||
|
t.Fatalf("finite repeated link resolved to %q", got.destination.Path)
|
||||||
|
}
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Fatal("symlink resolution did not finish")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
cancel()
|
||||||
|
if _, err := tree.ResolveDestination(ctx, "README.md", "alias/README.md"); !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("canceled resolution error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveDestinationIgnoresNeighboringFiles(t *testing.T) {
|
func TestResolveDestinationIgnoresNeighboringFiles(t *testing.T) {
|
||||||
repository := newTreeRepository(t)
|
repository := newTreeRepository(t)
|
||||||
writeTreeFile(t, repository, "docs/page.md", []byte("source\n"))
|
writeTreeFile(t, repository, "docs/page.md", []byte("source\n"))
|
||||||
|
|||||||
@@ -100,65 +100,73 @@ type Destination struct {
|
|||||||
// ResolveDestination resolves a repository-relative destination from the
|
// ResolveDestination resolves a repository-relative destination from the
|
||||||
// source file's directory using only the captured Git tree.
|
// source file's directory using only the captured Git tree.
|
||||||
func (tree *CandidateTree) ResolveDestination(ctx context.Context, sourcePath, destination string) (Destination, error) {
|
func (tree *CandidateTree) ResolveDestination(ctx context.Context, sourcePath, destination string) (Destination, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return Destination{}, err
|
||||||
|
}
|
||||||
if strings.HasPrefix(destination, "/") {
|
if strings.HasPrefix(destination, "/") {
|
||||||
return Destination{Kind: DestinationOutsideCheckout, Path: path.Clean(destination)}, nil
|
return Destination{Kind: DestinationOutsideCheckout, Path: path.Clean(destination)}, nil
|
||||||
}
|
}
|
||||||
resolved := path.Clean(path.Join(path.Dir(sourcePath), destination))
|
resolved := path.Clean(path.Join(path.Dir(sourcePath), destination))
|
||||||
|
return tree.resolveDestination(ctx, resolved, make(map[string]bool))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tree *CandidateTree) resolveDestination(ctx context.Context, resolved string, active map[string]bool) (Destination, error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return Destination{}, err
|
||||||
|
}
|
||||||
if outsideCheckout(resolved) {
|
if outsideCheckout(resolved) {
|
||||||
return Destination{Kind: DestinationOutsideCheckout, Path: resolved}, nil
|
return Destination{Kind: DestinationOutsideCheckout, Path: resolved}, nil
|
||||||
}
|
}
|
||||||
seen := make(map[string]struct{})
|
components := strings.Split(resolved, "/")
|
||||||
for {
|
partial := ""
|
||||||
components := strings.Split(resolved, "/")
|
for index, component := range components {
|
||||||
partial := ""
|
if err := ctx.Err(); err != nil {
|
||||||
restarted := false
|
return Destination{}, err
|
||||||
for index, component := range components {
|
}
|
||||||
partial = path.Join(partial, component)
|
partial = path.Join(partial, component)
|
||||||
remaining := strings.Join(components[index+1:], "/")
|
remaining := strings.Join(components[index+1:], "/")
|
||||||
entry, exists := tree.Entry(partial)
|
entry, exists := tree.Entry(partial)
|
||||||
if !exists {
|
if !exists {
|
||||||
if _, directory := tree.directories[partial]; !directory {
|
if _, directory := tree.directories[partial]; !directory {
|
||||||
return Destination{Kind: DestinationMissing, Path: resolved}, nil
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if entry.Mode == "160000" {
|
|
||||||
return Destination{Kind: DestinationSubmodule, Path: path.Join(partial, remaining)}, nil
|
|
||||||
}
|
|
||||||
if entry.Mode == "120000" {
|
|
||||||
state := partial + "\x00" + remaining
|
|
||||||
if _, cycle := seen[state]; cycle {
|
|
||||||
return Destination{Kind: DestinationSymlinkCycle, Path: partial}, nil
|
|
||||||
}
|
|
||||||
seen[state] = struct{}{}
|
|
||||||
linkText, err := tree.ReadFile(ctx, partial)
|
|
||||||
if err != nil {
|
|
||||||
return Destination{}, fmt.Errorf("read committed symlink %q: %w", partial, err)
|
|
||||||
}
|
|
||||||
target := string(linkText)
|
|
||||||
if strings.HasPrefix(target, "/") {
|
|
||||||
return Destination{Kind: DestinationOutsideCheckout, Path: target}, nil
|
|
||||||
}
|
|
||||||
resolved = path.Clean(path.Join(path.Dir(partial), target, remaining))
|
|
||||||
if outsideCheckout(resolved) {
|
|
||||||
return Destination{Kind: DestinationOutsideCheckout, Path: resolved}, nil
|
|
||||||
}
|
|
||||||
restarted = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if remaining != "" {
|
|
||||||
return Destination{Kind: DestinationMissing, Path: resolved}, nil
|
return Destination{Kind: DestinationMissing, Path: resolved}, nil
|
||||||
}
|
}
|
||||||
return Destination{Kind: DestinationEntry, Path: partial}, nil
|
|
||||||
}
|
|
||||||
if restarted {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, directory := tree.directories[resolved]; directory {
|
if entry.Mode == "160000" {
|
||||||
return Destination{Kind: DestinationDirectory, Path: resolved}, nil
|
return Destination{Kind: DestinationSubmodule, Path: path.Join(partial, remaining)}, nil
|
||||||
}
|
}
|
||||||
return Destination{Kind: DestinationMissing, Path: resolved}, nil
|
if entry.Mode == "120000" {
|
||||||
|
if active[partial] {
|
||||||
|
return Destination{Kind: DestinationSymlinkCycle, Path: partial}, nil
|
||||||
|
}
|
||||||
|
linkText, err := tree.ReadFile(ctx, partial)
|
||||||
|
if err != nil {
|
||||||
|
return Destination{}, fmt.Errorf("read committed symlink %q: %w", partial, err)
|
||||||
|
}
|
||||||
|
target := string(linkText)
|
||||||
|
if strings.HasPrefix(target, "/") {
|
||||||
|
return Destination{Kind: DestinationOutsideCheckout, Path: target}, nil
|
||||||
|
}
|
||||||
|
active[partial] = true
|
||||||
|
destination, err := tree.resolveDestination(ctx, path.Join(path.Dir(partial), target), active)
|
||||||
|
delete(active, partial)
|
||||||
|
if err != nil || remaining == "" || destination.Kind != DestinationDirectory {
|
||||||
|
if err == nil && remaining != "" && destination.Kind == DestinationEntry {
|
||||||
|
return Destination{Kind: DestinationMissing, Path: path.Join(destination.Path, remaining)}, nil
|
||||||
|
}
|
||||||
|
return destination, err
|
||||||
|
}
|
||||||
|
return tree.resolveDestination(ctx, path.Join(destination.Path, remaining), active)
|
||||||
|
}
|
||||||
|
if remaining != "" {
|
||||||
|
return Destination{Kind: DestinationMissing, Path: resolved}, nil
|
||||||
|
}
|
||||||
|
return Destination{Kind: DestinationEntry, Path: partial}, nil
|
||||||
}
|
}
|
||||||
|
if _, directory := tree.directories[resolved]; directory {
|
||||||
|
return Destination{Kind: DestinationDirectory, Path: resolved}, nil
|
||||||
|
}
|
||||||
|
return Destination{Kind: DestinationMissing, Path: resolved}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func outsideCheckout(name string) bool {
|
func outsideCheckout(name string) bool {
|
||||||
|
|||||||
Reference in New Issue
Block a user