Resolve candidate file destinations
This commit is contained in:
@@ -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
|
the default offline Go suite, then runs product-specific checks once. The
|
||||||
candidate package owns post-validation builds and local-state rechecks;
|
candidate package owns post-validation builds and local-state rechecks;
|
||||||
publication owns endpoint guards, tag creation, push, and recovery around that
|
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
|
## Capability owners and behavioral evidence
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Validation model implementation plan
|
# 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
|
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
|
[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
|
## 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
|
**Outcome:** Future Markdown validation can resolve committed local destinations
|
||||||
and classify checkout boundaries without filesystem traversal.
|
and classify checkout boundaries without filesystem traversal.
|
||||||
|
|
||||||
|
|||||||
115
internal/validation/destination_test.go
Normal file
115
internal/validation/destination_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ package validation
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
"gitea.maximumdirect.net/eric/ReleaseKit/internal/git"
|
||||||
)
|
)
|
||||||
@@ -13,6 +15,7 @@ type CandidateTree struct {
|
|||||||
repositoryDir string
|
repositoryDir string
|
||||||
entries []git.TreeEntry
|
entries []git.TreeEntry
|
||||||
byPath map[string]int
|
byPath map[string]int
|
||||||
|
directories map[string]struct{}
|
||||||
blobs map[string][]byte
|
blobs map[string][]byte
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,10 +30,15 @@ func ReadCandidateTree(ctx context.Context, repositoryDir, commit string) (*Cand
|
|||||||
repositoryDir: repositoryDir,
|
repositoryDir: repositoryDir,
|
||||||
entries: entries,
|
entries: entries,
|
||||||
byPath: make(map[string]int, len(entries)),
|
byPath: make(map[string]int, len(entries)),
|
||||||
|
directories: make(map[string]struct{}),
|
||||||
blobs: make(map[string][]byte),
|
blobs: make(map[string][]byte),
|
||||||
}
|
}
|
||||||
|
tree.directories["."] = struct{}{}
|
||||||
for index, entry := range entries {
|
for index, entry := range entries {
|
||||||
tree.byPath[entry.Path] = index
|
tree.byPath[entry.Path] = index
|
||||||
|
for directory := path.Dir(entry.Path); directory != "."; directory = path.Dir(directory) {
|
||||||
|
tree.directories[directory] = struct{}{}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return tree, nil
|
return tree, nil
|
||||||
}
|
}
|
||||||
@@ -70,3 +78,89 @@ func (tree *CandidateTree) Entry(path string) (git.TreeEntry, bool) {
|
|||||||
}
|
}
|
||||||
return tree.entries[index], true
|
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, "../")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user