diff --git a/internal/hooks/discover.go b/internal/hooks/discover.go new file mode 100644 index 0000000..364c9f0 --- /dev/null +++ b/internal/hooks/discover.go @@ -0,0 +1,94 @@ +// This file validates one scope's _hooks tree into immutable hook +// descriptors (PLAN.md Section 10.2). Discovery is read-only: it uses +// os.Lstat and os.ReadDir only, never executes a hook, imports a process +// helper, or inspects the target tree. A hooks root or phase path that is +// not a real directory, and any direct child that is not an executable +// regular file, is a validation error rather than a silent skip. +package hooks + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + + "github.com/alyraffauf/cattery/internal/deployment" +) + +// Discover validates the _hooks tree of one scope beneath root and returns +// the immutable hook descriptors sorted by phase and bytewise name. A scope +// without a _hooks tree yields no hooks, not an error. +func Discover(root string, scope deployment.Scope) ([]deployment.Hook, error) { + hooksRoot := filepath.Join(root, scope.Group, "_hooks") + info, err := os.Lstat(hooksRoot) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + return nil, err + } + if !info.IsDir() { + return nil, fmt.Errorf("hooks: %q is not a directory", hooksRoot) + } + var discovered []deployment.Hook + for _, phase := range []deployment.HookPhase{deployment.HookBefore, deployment.HookAfter} { + found, err := discoverPhase(hooksRoot, scope, phase) + if err != nil { + return nil, err + } + discovered = append(discovered, found...) + } + deployment.SortHooks(discovered) + return discovered, nil +} + +// discoverPhase validates one before/after directory beneath the hooks root. +func discoverPhase(hooksRoot string, scope deployment.Scope, phase deployment.HookPhase) ([]deployment.Hook, error) { + phasePath := filepath.Join(hooksRoot, string(phase)) + info, err := os.Lstat(phasePath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + return nil, err + } + if !info.IsDir() { + return nil, fmt.Errorf("hooks: phase path %q is not a directory", phasePath) + } + entries, err := os.ReadDir(phasePath) + if err != nil { + return nil, err + } + var discovered []deployment.Hook + for _, entry := range entries { + found, err := discoverEntry(scope, phasePath, entry) + if err != nil { + return nil, err + } + discovered = append(discovered, found) + } + return discovered, nil +} + +// discoverEntry validates one direct child and builds its descriptor. A +// directory, symlink, special entry, or non-executable file is rejected. +func discoverEntry(scope deployment.Scope, phasePath string, entry os.DirEntry) (deployment.Hook, error) { + phase := deployment.HookPhase(filepath.Base(phasePath)) + full := filepath.Join(phasePath, entry.Name()) + info, err := entry.Info() + if err != nil { + return deployment.Hook{}, err + } + if !info.Mode().IsRegular() { + return deployment.Hook{}, fmt.Errorf("hooks: %q is not a regular file", full) + } + if info.Mode().Perm()&0o111 == 0 { + return deployment.Hook{}, fmt.Errorf("hooks: %q is not executable", full) + } + return deployment.NewHook(deployment.Hook{ + Scope: scope, Phase: phase, Name: entry.Name(), + AbsolutePath: full, + RepositoryPath: filepath.Join(scope.Group, "_hooks", string(phase), entry.Name()), + }) +} diff --git a/internal/hooks/discover_test.go b/internal/hooks/discover_test.go new file mode 100644 index 0000000..acc603a --- /dev/null +++ b/internal/hooks/discover_test.go @@ -0,0 +1,185 @@ +package hooks + +import ( + "os" + "path/filepath" + "syscall" + "testing" + + "github.com/alyraffauf/cattery/internal/deployment" +) + +func TestHookDiscovery(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"valid hooks discovered", testDiscoveryValid}, + {"missing hooks tree is empty", testDiscoveryMissing}, + {"empty hooks tree is empty", testDiscoveryEmpty}, + {"nonexecutable rejected", testDiscoveryNonexecutable}, + {"nested directory rejected", testDiscoveryNested}, + {"symlink rejected", testDiscoverySymlink}, + {"special file rejected", testDiscoverySpecial}, + {"group hooks scoped", testDiscoveryGroup}, + {"phase not a directory rejected", testDiscoveryPhaseFile}, + {"hooks root not a directory rejected", testDiscoveryHooksFile}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +func testDiscoveryValid(t *testing.T) { + root := t.TempDir() + writeMode(t, filepath.Join(root, "_hooks", "before", "a.sh"), 0o755) + writeMode(t, filepath.Join(root, "_hooks", "before", "b.sh"), 0o700) + writeMode(t, filepath.Join(root, "_hooks", "after", "z.sh"), 0o755) + got, err := Discover(root, deployment.NewScope("")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertHooks(t, got, []deployment.Hook{ + { + Scope: deployment.NewScope(""), Phase: deployment.HookAfter, Name: "z.sh", + AbsolutePath: filepath.Join(root, "_hooks", "after", "z.sh"), + RepositoryPath: "_hooks/after/z.sh", + }, + { + Scope: deployment.NewScope(""), Phase: deployment.HookBefore, Name: "a.sh", + AbsolutePath: filepath.Join(root, "_hooks", "before", "a.sh"), + RepositoryPath: "_hooks/before/a.sh", + }, + { + Scope: deployment.NewScope(""), Phase: deployment.HookBefore, Name: "b.sh", + AbsolutePath: filepath.Join(root, "_hooks", "before", "b.sh"), + RepositoryPath: "_hooks/before/b.sh", + }, + }) +} + +func testDiscoveryMissing(t *testing.T) { + root := t.TempDir() + got, err := Discover(root, deployment.NewScope("")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 0 { + t.Fatalf("hooks = %d, want none", len(got)) + } +} + +func testDiscoveryEmpty(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "_hooks"), 0o755); err != nil { + t.Fatal(err) + } + got, err := Discover(root, deployment.NewScope("")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 0 { + t.Fatalf("hooks = %d, want none", len(got)) + } +} + +func testDiscoveryNonexecutable(t *testing.T) { + root := t.TempDir() + writeMode(t, filepath.Join(root, "_hooks", "before", "stale.sh"), 0o644) + if _, err := Discover(root, deployment.NewScope("")); err == nil { + t.Fatal("nonexecutable hook was accepted") + } +} + +func testDiscoveryNested(t *testing.T) { + root := t.TempDir() + writeMode(t, filepath.Join(root, "_hooks", "before", "sub", "x.sh"), 0o755) + if _, err := Discover(root, deployment.NewScope("")); err == nil { + t.Fatal("nested hook directory was accepted") + } +} + +func testDiscoverySymlink(t *testing.T) { + root := t.TempDir() + writeMode(t, filepath.Join(root, "target.sh"), 0o755) + if err := os.MkdirAll(filepath.Join(root, "_hooks", "before"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink("../../target.sh", filepath.Join(root, "_hooks", "before", "link.sh")); err != nil { + t.Fatal(err) + } + if _, err := Discover(root, deployment.NewScope("")); err == nil { + t.Fatal("symlinked hook was accepted") + } +} + +func testDiscoverySpecial(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "_hooks", "before"), 0o755); err != nil { + t.Fatal(err) + } + if err := syscall.Mkfifo(filepath.Join(root, "_hooks", "before", "pipe"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := Discover(root, deployment.NewScope("")); err == nil { + t.Fatal("special hook entry was accepted") + } +} + +func testDiscoveryGroup(t *testing.T) { + root := t.TempDir() + writeMode(t, filepath.Join(root, "_hooks", "before", "root.sh"), 0o755) + writeMode(t, filepath.Join(root, "atuin", "_hooks", "after", "group.sh"), 0o755) + got, err := Discover(root, deployment.NewScope("atuin")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + assertHooks(t, got, []deployment.Hook{ + { + Scope: deployment.NewScope("atuin"), Phase: deployment.HookAfter, Name: "group.sh", + AbsolutePath: filepath.Join(root, "atuin", "_hooks", "after", "group.sh"), + RepositoryPath: "atuin/_hooks/after/group.sh", + }, + }) +} + +func testDiscoveryPhaseFile(t *testing.T) { + root := t.TempDir() + writeMode(t, filepath.Join(root, "_hooks", "before"), 0o644) + if _, err := Discover(root, deployment.NewScope("")); err == nil { + t.Fatal("file phase path was accepted as a hook directory") + } +} + +func testDiscoveryHooksFile(t *testing.T) { + root := t.TempDir() + writeMode(t, filepath.Join(root, "_hooks"), 0o644) + if _, err := Discover(root, deployment.NewScope("")); err == nil { + t.Fatal("file hooks root was accepted") + } +} + +func assertHooks(t *testing.T, got []deployment.Hook, want []deployment.Hook) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("hooks = %d, want %d", len(got), len(want)) + } + for index := range want { + if got[index] != want[index] { + t.Fatalf("hook %d = %+v, want %+v", index, got[index], want[index]) + } + } +} + +func writeMode(t *testing.T, path string, mode os.FileMode) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, nil, mode); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, mode); err != nil { + t.Fatal(err) + } +}