From bc5ff0dbe3a15e7e797de302007a295ef1713873 Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Sun, 9 Aug 2026 15:19:12 -0400 Subject: [PATCH] feat: scan repository sources --- internal/repository/scan.go | 250 +++++++++++++++++++++++++++++++ internal/repository/scan_test.go | 231 ++++++++++++++++++++++++++++ 2 files changed, 481 insertions(+) create mode 100644 internal/repository/scan.go create mode 100644 internal/repository/scan_test.go diff --git a/internal/repository/scan.go b/internal/repository/scan.go new file mode 100644 index 0000000..f9736e6 --- /dev/null +++ b/internal/repository/scan.go @@ -0,0 +1,250 @@ +// This file scans repository trees into base-layer candidates and raw hook +// candidates (PLAN Task 28); overlay and route work belong to later phases. +package repository + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/alyraffauf/cattery/internal/deployment" + "github.com/alyraffauf/cattery/internal/pathsafe" +) + +// Candidate is one regular-file base-layer source candidate; SourceRepoPath +// is repository-relative and SourceAbsPath is absolute. +type Candidate struct { + Scope deployment.Scope + Layer deployment.Layer + Kind deployment.FileKind + SourceRepoPath string + SourceAbsPath string + ExecutableBits fs.FileMode +} + +// HookCandidate is one raw regular-file child of a scope _hooks tree; hook +// semantics are validated by hook discovery, not by the scanner. +type HookCandidate struct { + Scope deployment.Scope + Phase deployment.HookPhase + Name string + AbsolutePath string + RepositoryPath string +} + +// ScanResult is the deterministic output of one repository scan. +type ScanResult struct { + Groups []string + Files []Candidate + Hooks []HookCandidate +} + +// Scan returns the base-layer candidates, raw hook candidates, and group +// names of the repository at root in deterministic order. Symlinks and +// special entries are rejected. +func Scan(root string) (ScanResult, error) { + scanner := scopeScanner{repoRoot: root, rootTree: true} + if err := scanner.scanScopeRoot(); err != nil { + return ScanResult{}, err + } + if err := checkGroupCollisions(scanner.groups); err != nil { + return ScanResult{}, err + } + return ScanResult{Groups: scanner.groups, Files: scanner.files, Hooks: scanner.hooks}, nil +} + +// scopeScanner accumulates candidates while scanning one scope root. +type scopeScanner struct { + repoRoot string + scopeRoot string + scope deployment.Scope + rootTree bool + groups []string + files []Candidate + hooks []HookCandidate +} + +func (s *scopeScanner) scanScopeRoot() error { + entries, err := os.ReadDir(filepath.Join(s.repoRoot, s.scopeRoot)) + if err != nil { + return err + } + for _, entry := range entries { + if err := s.scanEntry(entry); err != nil { + return err + } + } + return nil +} + +// scanEntry dispatches one scope-root entry. +func (s *scopeScanner) scanEntry(entry os.DirEntry) error { + control := ClassifyRoot(entry.Name()) + switch { + case s.rootTree && control == ControlNone && entry.IsDir() && !strings.HasPrefix(entry.Name(), "."): + return s.beginGroup(entry) + case control == ControlNone: + return s.scanOrdinary(entry) + case control == ControlSecrets: + return s.scanSecrets(entry) + case control == ControlHooks: + return s.scanHooks(entry) + case control == ControlMetadata: + if s.rootTree { + return nil + } + return s.scanOrdinary(entry) + default: + return nil + } +} + +// beginGroup validates a group name and scans its scope. +func (s *scopeScanner) beginGroup(entry os.DirEntry) error { + name := entry.Name() + if err := pathsafe.GroupName(name); err != nil { + return err + } + s.groups = append(s.groups, name) + root, scope := s.scopeRoot, s.scope + s.scopeRoot = filepath.Join(s.scopeRoot, name) + s.scope = deployment.NewScope(name) + s.rootTree = false + err := s.scanScopeRoot() + s.scopeRoot, s.scope, s.rootTree = root, scope, true + return err +} + +func (s *scopeScanner) scanOrdinary(entry os.DirEntry) error { + if entry.IsDir() { + return s.walkTree(entry.Name(), deployment.FileOrdinary) + } + if !entry.Type().IsRegular() { + return s.nonRegular(entry.Name()) + } + return s.addFileAt(entry.Name(), entry, deployment.FileOrdinary) +} + +func (s *scopeScanner) scanSecrets(entry os.DirEntry) error { + if !entry.IsDir() { + return fmt.Errorf("repository: control %q is not a directory", entry.Name()) + } + return s.walkTree(entry.Name(), deployment.FileSecret) +} + +func (s *scopeScanner) scanHooks(entry os.DirEntry) error { + if !entry.IsDir() { + return fmt.Errorf("repository: control %q is not a directory", entry.Name()) + } + for _, phase := range []deployment.HookPhase{deployment.HookBefore, deployment.HookAfter} { + if err := s.scanHookPhase(entry.Name(), phase); err != nil { + return err + } + } + return nil +} + +func (s *scopeScanner) scanHookPhase(hooks string, phase deployment.HookPhase) error { + path := filepath.Join(s.repoRoot, s.scopeRoot, hooks, string(phase)) + info, err := os.Lstat(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return err + } + if !info.IsDir() { + return nil + } + entries, err := os.ReadDir(path) + if err != nil { + return err + } + for _, entry := range entries { + if entry.Type().IsRegular() { + s.hooks = append(s.hooks, s.hookCandidate(hooks, phase, entry.Name())) + } + } + return nil +} + +// walkTree visits a literal subtree beneath relative. +func (s *scopeScanner) walkTree(relative string, kind deployment.FileKind) error { + entries, err := os.ReadDir(filepath.Join(s.repoRoot, s.scopeRoot, relative)) + if err != nil { + return err + } + for _, entry := range entries { + if err := s.walkEntry(relative, entry, kind); err != nil { + return err + } + } + return nil +} + +func (s *scopeScanner) walkEntry(parent string, entry os.DirEntry, kind deployment.FileKind) error { + path := filepath.Join(parent, entry.Name()) + if entry.IsDir() { + return s.walkTree(path, kind) + } + if !entry.Type().IsRegular() { + return s.nonRegular(path) + } + return s.addFileAt(path, entry, kind) +} + +func (s *scopeScanner) addFileAt(relative string, entry os.DirEntry, kind deployment.FileKind) error { + info, err := entry.Info() + if err != nil { + return err + } + path := filepath.Join(s.scopeRoot, relative) + s.files = append(s.files, Candidate{ + Scope: s.scope, + Layer: deployment.LayerBase, + Kind: kind, + SourceRepoPath: path, + SourceAbsPath: filepath.Join(s.repoRoot, path), + ExecutableBits: info.Mode() & 0o111, + }) + return nil +} + +func (s *scopeScanner) hookCandidate(hooks string, phase deployment.HookPhase, name string) HookCandidate { + path := filepath.Join(s.scopeRoot, hooks, string(phase), name) + return HookCandidate{ + Scope: s.scope, + Phase: phase, + Name: name, + AbsolutePath: filepath.Join(s.repoRoot, path), + RepositoryPath: path, + } +} + +func (s *scopeScanner) nonRegular(relative string) error { + return fmt.Errorf("repository: non-regular source entry %q", filepath.Join(s.scopeRoot, relative)) +} + +// checkGroupCollisions rejects group names equivalent under PLAN 2.1, 6.3. +func checkGroupCollisions(groups []string) error { + for first := 0; first < len(groups); first++ { + if match := duplicateGroupIndex(groups, first); match >= 0 { + return fmt.Errorf("repository: group %q collides with group %q under portable equivalence", groups[first], groups[match]) + } + } + return nil +} + +// duplicateGroupIndex returns the first later group index equivalent to +// groups[first], or -1. +func duplicateGroupIndex(groups []string, first int) int { + for second := first + 1; second < len(groups); second++ { + if pathsafe.SegmentsEquivalent(groups[first], groups[second]) { + return second + } + } + return -1 +} diff --git a/internal/repository/scan_test.go b/internal/repository/scan_test.go new file mode 100644 index 0000000..f40af5e --- /dev/null +++ b/internal/repository/scan_test.go @@ -0,0 +1,231 @@ +package repository + +import ( + "io/fs" + "os" + "path/filepath" + "syscall" + "testing" + + "github.com/alyraffauf/cattery/internal/deployment" +) + +func TestRepositoryScan(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"root files", testScanRootFiles}, + {"root dot trees", testScanDotTrees}, + {"groups", testScanGroups}, + {"empty files", testScanEmptyFiles}, + {"controls are excluded", testScanControlsExcluded}, + {"raw hook candidates", testScanHookCandidates}, + {"symlinks rejected", testScanSymlinkRejected}, + {"specials rejected", testScanSpecialRejected}, + {"group collisions", testScanGroupCollisions}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +func testScanRootFiles(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, ".bashrc"), 0o644) + writeFile(t, filepath.Join(root, "Brewfile"), 0o755) + result, err := Scan(root) + if err != nil { + t.Fatal(err) + } + if len(result.Groups) != 0 { + t.Fatalf("groups = %v, want none", result.Groups) + } + assertCandidate(t, result.Files[0], newCandidate(root, wantFile{repoPath: ".bashrc"})) + assertCandidate(t, result.Files[1], newCandidate(root, wantFile{repoPath: "Brewfile", exec: 0o111})) + if len(result.Files) != 2 { + t.Fatalf("files = %d, want 2", len(result.Files)) + } +} + +func testScanDotTrees(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, ".config", "starship.toml"), 0o644) + writeFile(t, filepath.Join(root, ".config", "app", "_internal", "value"), 0o600) + result, err := Scan(root) + if err != nil { + t.Fatal(err) + } + if len(result.Groups) != 0 { + t.Fatalf("dot tree mistaken for a group: %v", result.Groups) + } + assertCandidate(t, result.Files[0], newCandidate(root, wantFile{repoPath: ".config/app/_internal/value"})) + assertCandidate(t, result.Files[1], newCandidate(root, wantFile{repoPath: ".config/starship.toml"})) +} + +func testScanGroups(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "atuin", ".config", "atuin", "config.toml"), 0o644) + writeFile(t, filepath.Join(root, "atuin", "Brewfile"), 0o644) + writeFile(t, filepath.Join(root, "ghostty", "config"), 0o755) + result, err := Scan(root) + if err != nil { + t.Fatal(err) + } + if len(result.Groups) != 2 || result.Groups[0] != "atuin" || result.Groups[1] != "ghostty" { + t.Fatalf("groups = %v, want [atuin ghostty]", result.Groups) + } + assertCandidate(t, result.Files[0], newCandidate(root, wantFile{scope: deployment.NewScope("atuin"), repoPath: "atuin/.config/atuin/config.toml"})) + assertCandidate(t, result.Files[1], newCandidate(root, wantFile{scope: deployment.NewScope("atuin"), repoPath: "atuin/Brewfile"})) + assertCandidate(t, result.Files[2], newCandidate(root, wantFile{scope: deployment.NewScope("ghostty"), repoPath: "ghostty/config", exec: 0o111})) +} + +func testScanEmptyFiles(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "empty.txt"), 0o644) + writeFile(t, filepath.Join(root, "atuin", "also-empty"), 0o600) + result, err := Scan(root) + if err != nil { + t.Fatal(err) + } + assertCandidate(t, result.Files[0], newCandidate(root, wantFile{scope: deployment.NewScope("atuin"), repoPath: "atuin/also-empty"})) + assertCandidate(t, result.Files[1], newCandidate(root, wantFile{repoPath: "empty.txt"})) +} + +func testScanControlsExcluded(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "_darwin", "x"), 0o644) + writeFile(t, filepath.Join(root, "_linux", "y"), 0o644) + writeFile(t, filepath.Join(root, "_secrets", "token"), 0o600) + writeFile(t, filepath.Join(root, "_routes.toml"), 0o644) + writeFile(t, filepath.Join(root, "_notes", "junk.txt"), 0o644) + writeFile(t, filepath.Join(root, ".git", "HEAD"), 0o644) + writeFile(t, filepath.Join(root, ".gitignore"), 0o644) + writeFile(t, filepath.Join(root, ".sops.yaml"), 0o644) + result, err := Scan(root) + if err != nil { + t.Fatal(err) + } + if len(result.Files) != 1 { + t.Fatalf("files = %d, want only the secret candidate", len(result.Files)) + } + assertCandidate(t, result.Files[0], newCandidate(root, wantFile{repoPath: "_secrets/token", secret: true})) +} + +func testScanHookCandidates(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "_hooks", "before", "install.sh"), 0o755) + writeFile(t, filepath.Join(root, "atuin", "_hooks", "after", "finish.sh"), 0o755) + result, err := Scan(root) + if err != nil { + t.Fatal(err) + } + if len(result.Hooks) != 2 { + t.Fatalf("hooks = %d, want 2", len(result.Hooks)) + } + assertHook(t, result.Hooks[0], HookCandidate{Scope: deployment.NewScope(""), Phase: deployment.HookBefore, Name: "install.sh", RepositoryPath: "_hooks/before/install.sh", AbsolutePath: filepath.Join(root, "_hooks", "before", "install.sh")}) + assertHook(t, result.Hooks[1], HookCandidate{Scope: deployment.NewScope("atuin"), Phase: deployment.HookAfter, Name: "finish.sh", RepositoryPath: "atuin/_hooks/after/finish.sh", AbsolutePath: filepath.Join(root, "atuin", "_hooks", "after", "finish.sh")}) +} + +func testScanSymlinkRejected(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "target"), 0o644) + if err := os.MkdirAll(filepath.Join(root, ".config"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink("target", filepath.Join(root, ".config", "live")); err != nil { + t.Fatal(err) + } + if _, err := Scan(root); err == nil { + t.Fatal("symlink inside a target tree was accepted") + } + symlinkRoot := t.TempDir() + writeFile(t, filepath.Join(symlinkRoot, "target"), 0o644) + if err := os.Symlink("target", filepath.Join(symlinkRoot, "live")); err != nil { + t.Fatal(err) + } + if _, err := Scan(symlinkRoot); err == nil { + t.Fatal("root-level symlink was accepted") + } +} + +func testScanSpecialRejected(t *testing.T) { + root := t.TempDir() + if err := syscall.Mkfifo(filepath.Join(root, "pipe"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := Scan(root); err == nil { + t.Fatal("special file was accepted") + } +} + +func testScanGroupCollisions(t *testing.T) { + scenarios := []struct { + name string + first string + second string + }{ + {"case-fold equivalent", "atuin", "Atuin"}, + {"NFC and NFD equivalent", "café", "cafe\u0301"}, + } + for _, scenario := range scenarios { + root := t.TempDir() + writeFile(t, filepath.Join(root, scenario.first, "x"), 0o644) + writeFile(t, filepath.Join(root, scenario.second, "y"), 0o644) + if _, err := Scan(root); err == nil { + t.Fatalf("%s: colliding groups were accepted", scenario.name) + } + } + invalidRoot := t.TempDir() + if err := os.MkdirAll(filepath.Join(invalidRoot, "\xff"), 0o755); err != nil { + t.Fatal(err) + } + if _, err := Scan(invalidRoot); err == nil { + t.Fatal("invalid-utf-8 group name was accepted") + } +} + +// wantFile describes one expected base candidate compactly. +type wantFile struct { + scope deployment.Scope + repoPath string + exec fs.FileMode + secret bool +} + +// newCandidate builds the expected Candidate for a repo-relative path. +func newCandidate(root string, want wantFile) Candidate { + kind := deployment.FileOrdinary + if want.secret { + kind = deployment.FileSecret + } + return Candidate{ + Scope: want.scope, Layer: deployment.LayerBase, Kind: kind, + SourceRepoPath: want.repoPath, SourceAbsPath: filepath.Join(root, want.repoPath), + ExecutableBits: want.exec, + } +} + +func assertCandidate(t *testing.T, got Candidate, want Candidate) { + t.Helper() + if got != want { + t.Fatalf("candidate = %+v, want %+v", got, want) + } +} + +func assertHook(t *testing.T, got HookCandidate, want HookCandidate) { + t.Helper() + if got != want { + t.Fatalf("hook candidate = %+v, want %+v", got, want) + } +} + +func writeFile(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) + } +} -- 2.51.2