diff --git a/docs/internal/overview.md b/docs/internal/overview.md index 4f2ed8b..65e2b97 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -64,7 +64,9 @@ in `internal/candidate`. Its validation bridge calls `internal/validation` for the default offline Go suite, then runs product-specific checks once. The candidate package owns post-validation builds and local-state rechecks; publication owns endpoint guards, tag creation, push, and recovery around that -same candidate validation path. +same candidate validation path. `internal/validation.CandidateTree` reads +committed blobs lazily and resolves local destinations from committed entries; +it does not consult neighboring files or traverse submodule checkouts. ## Capability owners and behavioral evidence diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index e290355..c46bb0b 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Validation model implementation plan -Status: In progress; the shared executor, internal diagnostics, and committed-file reader are complete. +Status: In progress; the shared executor, diagnostics, committed-file reader, and destination resolver are complete. This plan implements the enabling work in [model.md](model.md), guided by [ADR 0003](../adr/0003-declarative-validation.md). It is written for a Luna @@ -254,6 +254,11 @@ checkout. Confirm inspection leaves repository contents unchanged. ## Stage 4: Add candidate destination-resolution primitives +**Completion:** Complete. The candidate tree resolves committed relative paths +and symlinks, preserving directory identities and classifying missing, +outside-checkout, submodule, and cycle observations without reading the live +filesystem. + **Outcome:** Future Markdown validation can resolve committed local destinations and classify checkout boundaries without filesystem traversal. diff --git a/internal/validation/destination_test.go b/internal/validation/destination_test.go new file mode 100644 index 0000000..a16ca07 --- /dev/null +++ b/internal/validation/destination_test.go @@ -0,0 +1,115 @@ +package validation + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestResolveDestinationClassifiesCommittedPaths(t *testing.T) { + repository := newTreeRepository(t) + writeTreeFile(t, repository, "README.md", []byte("root\n")) + writeTreeFile(t, repository, "docs/page.md", []byte("source\n")) + writeTreeFile(t, repository, "docs/target.md", []byte("target\n")) + writeTreeFile(t, repository, "docs/nested/item.txt", []byte("nested\n")) + writeTreeFile(t, repository, "docs/dir/item.txt", []byte("directory\n")) + for name, target := range map[string]string{ + "docs/inside": "target.md", + "docs/through-dir": "nested", + "docs/outside": "../../neighbor/file", + "docs/absolute": "/outside/file", + "docs/dangling": "missing-target", + "docs/cycle-a": "cycle-b", + "docs/cycle-b": "cycle-a", + } { + if err := os.Symlink(target, filepath.Join(repository, filepath.FromSlash(name))); err != nil { + t.Fatal(err) + } + } + submoduleSource := newTreeRepository(t) + writeTreeFile(t, submoduleSource, "sub.txt", []byte("submodule\n")) + gitTree(t, submoduleSource, "add", "--all") + gitTree(t, submoduleSource, "commit", "-m", "create submodule source") + submoduleOID := strings.TrimSpace(gitTree(t, submoduleSource, "rev-parse", "HEAD")) + gitTree(t, repository, "add", "--all") + gitTree(t, repository, "update-index", "--add", "--cacheinfo", "160000,"+submoduleOID+",docs/submodule") + gitTree(t, repository, "commit", "-m", "capture destination fixtures") + commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD")) + tree, err := ReadCandidateTree(context.Background(), repository, commit) + if err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(repository, "docs", "inside")); err != nil { + t.Fatal(err) + } + if err := os.Symlink("../../neighbor/file", filepath.Join(repository, "docs", "inside")); err != nil { + t.Fatal(err) + } + + cases := []struct { + name string + source string + destination string + kind DestinationKind + path string + }{ + {name: "relative file", source: "docs/page.md", destination: "target.md", kind: DestinationEntry, path: "docs/target.md"}, + {name: "parent traversal inside root", source: "docs/page.md", destination: "../README.md", kind: DestinationEntry, path: "README.md"}, + {name: "outside root", source: "docs/page.md", destination: "../../neighbor/file", kind: DestinationOutsideCheckout, path: "../neighbor/file"}, + {name: "repository root directory", source: "docs/page.md", destination: "..", kind: DestinationDirectory, path: "."}, + {name: "directory", source: "docs/page.md", destination: "dir", kind: DestinationDirectory, path: "docs/dir"}, + {name: "symlink to file", source: "docs/page.md", destination: "inside", kind: DestinationEntry, path: "docs/target.md"}, + {name: "symlinked directory", source: "docs/page.md", destination: "through-dir/item.txt", kind: DestinationEntry, path: "docs/nested/item.txt"}, + {name: "absolute symlink", source: "docs/page.md", destination: "absolute", kind: DestinationOutsideCheckout, path: "/outside/file"}, + {name: "missing entry", source: "docs/page.md", destination: "missing", kind: DestinationMissing, path: "docs/missing"}, + {name: "dangling symlink", source: "docs/page.md", destination: "dangling", kind: DestinationMissing, path: "docs/missing-target"}, + {name: "symlink cycle", source: "docs/page.md", destination: "cycle-a", kind: DestinationSymlinkCycle, path: "docs/cycle-a"}, + {name: "submodule contents", source: "docs/page.md", destination: "submodule/child", kind: DestinationSubmodule, path: "docs/submodule/child"}, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + got, err := tree.ResolveDestination(context.Background(), test.source, test.destination) + if err != nil { + t.Fatalf("ResolveDestination() error = %v", err) + } + if got.Kind != test.kind || got.Path != test.path { + t.Fatalf("ResolveDestination() = %#v, want kind %q path %q", got, test.kind, test.path) + } + }) + } +} + +func TestResolveDestinationIgnoresNeighboringFiles(t *testing.T) { + repository := newTreeRepository(t) + writeTreeFile(t, repository, "docs/page.md", []byte("source\n")) + if err := os.Symlink("../../neighbor/file", filepath.Join(repository, "docs", "outside")); err != nil { + t.Fatal(err) + } + gitTree(t, repository, "add", "--all") + gitTree(t, repository, "commit", "-m", "capture outside link") + commit := strings.TrimSpace(gitTree(t, repository, "rev-parse", "HEAD")) + tree, err := ReadCandidateTree(context.Background(), repository, commit) + if err != nil { + t.Fatal(err) + } + gotWithoutNeighbor, err := tree.ResolveDestination(context.Background(), "docs/page.md", "outside") + if err != nil { + t.Fatal(err) + } + neighbor := filepath.Join(filepath.Dir(repository), "neighbor", "file") + if err := os.MkdirAll(filepath.Dir(neighbor), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(neighbor, []byte("outside source\n"), 0o644); err != nil { + t.Fatal(err) + } + gotWithNeighbor, err := tree.ResolveDestination(context.Background(), "docs/page.md", "outside") + if err != nil { + t.Fatal(err) + } + if gotWithNeighbor != gotWithoutNeighbor || gotWithNeighbor.Kind != DestinationOutsideCheckout { + t.Fatalf("neighbor changed resolution: absent=%#v present=%#v", gotWithoutNeighbor, gotWithNeighbor) + } +} diff --git a/internal/validation/tree.go b/internal/validation/tree.go index c45ee4b..da9d062 100644 --- a/internal/validation/tree.go +++ b/internal/validation/tree.go @@ -3,6 +3,8 @@ package validation import ( "context" "fmt" + "path" + "strings" "gitea.maximumdirect.net/eric/ReleaseKit/internal/git" ) @@ -13,6 +15,7 @@ type CandidateTree struct { repositoryDir string entries []git.TreeEntry byPath map[string]int + directories map[string]struct{} blobs map[string][]byte } @@ -27,10 +30,15 @@ func ReadCandidateTree(ctx context.Context, repositoryDir, commit string) (*Cand repositoryDir: repositoryDir, entries: entries, byPath: make(map[string]int, len(entries)), + directories: make(map[string]struct{}), blobs: make(map[string][]byte), } + 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 } @@ -70,3 +78,89 @@ func (tree *CandidateTree) Entry(path string) (git.TreeEntry, bool) { } 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 a repository-relative destination from the +// source file's directory using only the captured Git tree. +func (tree *CandidateTree) ResolveDestination(ctx context.Context, sourcePath, destination string) (Destination, error) { + if strings.HasPrefix(destination, "/") { + return Destination{Kind: DestinationOutsideCheckout, Path: path.Clean(destination)}, nil + } + resolved := path.Clean(path.Join(path.Dir(sourcePath), destination)) + if outsideCheckout(resolved) { + return Destination{Kind: DestinationOutsideCheckout, Path: resolved}, nil + } + seen := make(map[string]struct{}) + for { + components := strings.Split(resolved, "/") + partial := "" + restarted := false + for index, component := range components { + partial = path.Join(partial, component) + remaining := strings.Join(components[index+1:], "/") + entry, exists := tree.Entry(partial) + if !exists { + 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: DestinationEntry, Path: partial}, nil + } + if restarted { + continue + } + 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 { + return name == ".." || strings.HasPrefix(name, "../") +}