diff --git a/internal/repository/overlay.go b/internal/repository/overlay.go new file mode 100644 index 0000000..c39ebd4 --- /dev/null +++ b/internal/repository/overlay.go @@ -0,0 +1,247 @@ +package repository + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/alyraffauf/cattery/internal/deployment" +) + +// ResolvePlatform merges the base scan with the platform layer tree. +func ResolvePlatform(root string, base ScanResult, platform deployment.Layer) ([]deployment.ManagedFile, error) { + if !platform.Valid() { + return nil, fmt.Errorf("repository: unknown platform layer %q", platform) + } + r := resolver{root: root, base: base, platform: platform} + rootView, err := scanLayerTree(root, deployment.NewScope(""), platform) + if err != nil { + return nil, err + } + records, err := resolveScopeFiles(r.base, deployment.NewScope(""), rootView) + if err != nil { + return nil, err + } + for _, group := range base.Groups { + groupRecords, err := r.resolveScope(deployment.NewScope(group), rootView) + if err != nil { + return nil, err + } + records = append(records, groupRecords...) + } + deployment.SortFiles(records) + return records, nil +} + +type resolver struct { + root string + base ScanResult + platform deployment.Layer +} + +// resolveScope merges one group scope, skipping groups replaced by files. +func (r *resolver) resolveScope(scope deployment.Scope, rootView layerView) ([]deployment.ManagedFile, error) { + if _, replaced := rootView.files[scope.Group]; replaced { + return nil, nil + } + view, err := scanLayerTree(r.root, scope, r.platform) + if err != nil { + return nil, err + } + return resolveScopeFiles(r.base, scope, view) +} + +type layerView struct { + files map[string]Candidate + dirs map[string]bool +} + +// covers reports whether the platform layer replaces a base target. +func (v layerView) covers(target string) bool { + if _, ok := v.files[target]; ok || v.dirs[target] { + return true + } + segments := strings.Split(target, "/") + for length := 1; length < len(segments); length++ { + prefix := strings.Join(segments[:length], "/") + if _, ok := v.files[prefix]; ok { + return true + } + } + return false +} + +func resolveScopeFiles(base ScanResult, scope deployment.Scope, platform layerView) ([]deployment.ManagedFile, error) { + merged := layerView{files: map[string]Candidate{}, dirs: map[string]bool{}} + for _, candidate := range base.Files { + if candidate.Scope != scope { + continue + } + target, err := baseTarget(candidate) + if err != nil { + return nil, err + } + if platform.covers(target) { + continue + } + existing, ok := merged.files[target] + if ok && existing.Kind != candidate.Kind { + return nil, fmt.Errorf("repository: ordinary and secret sources collide at %q", target) + } + merged.files[target] = candidate + } + for target, candidate := range platform.files { + merged.files[target] = candidate + } + return recordsFor(merged.files) +} + +func baseTarget(candidate Candidate) (string, error) { + target := candidate.SourceRepoPath + if !candidate.Scope.IsRoot() { + target = strings.TrimPrefix(target, candidate.Scope.Group+"/") + } + if candidate.Kind != deployment.FileSecret { + return target, nil + } + target = strings.TrimPrefix(target, "_secrets/") + if candidate.Scope.IsRoot() { + first := strings.Split(target, "/")[0] + representable := strings.HasPrefix(first, ".") || + (!strings.Contains(target, "/") && !strings.HasPrefix(first, "_")) + if !representable { + return "", fmt.Errorf("repository: root secret target %q is not representable at the root layer", target) + } + } + return target, nil +} + +func recordsFor(merged map[string]Candidate) ([]deployment.ManagedFile, error) { + records := make([]deployment.ManagedFile, 0, len(merged)) + for target, candidate := range merged { + record, err := deployment.NewManagedFile(deployment.ManagedFile{ + Scope: candidate.Scope, Layer: candidate.Layer, Kind: candidate.Kind, + SourceAbsolutePath: candidate.SourceAbsPath, SourceRepositoryPath: candidate.SourceRepoPath, + TargetRelativePath: target, SourceExecutableBits: candidate.ExecutableBits, + }) + if err != nil { + return nil, err + } + records = append(records, record) + } + return records, nil +} + +func scanLayerTree(root string, scope deployment.Scope, platform deployment.Layer) (layerView, error) { + relative := filepath.Join(scope.Group, "_"+string(platform)) + info, err := os.Lstat(filepath.Join(root, relative)) + if err != nil { + if os.IsNotExist(err) { + return layerView{}, nil + } + return layerView{}, err + } + if !info.IsDir() { + return layerView{}, fmt.Errorf("repository: platform layer %q is not a directory", relative) + } + walker := layerWalker{ + absolute: filepath.Join(root, relative), relative: relative, scope: scope, layer: platform, + view: layerView{files: map[string]Candidate{}, dirs: map[string]bool{}}, + } + if err := walker.walk("", deployment.FileOrdinary); err != nil { + return layerView{}, err + } + return walker.view, nil +} + +type layerWalker struct { + absolute string + relative string + scope deployment.Scope + layer deployment.Layer + view layerView +} + +func (w *layerWalker) walk(relative string, kind deployment.FileKind) error { + entries, err := os.ReadDir(filepath.Join(w.absolute, relative)) + if err != nil { + return err + } + for _, entry := range entries { + entryKind, skip, err := classifyEntry(relative, entry, kind) + if err != nil { + return err + } + if skip { + continue + } + if err := w.visit(filepath.Join(relative, entry.Name()), entry, entryKind); err != nil { + return err + } + } + return nil +} + +// classifyEntry returns the storage kind for one layer entry. +func classifyEntry(relative string, entry os.DirEntry, kind deployment.FileKind) (deployment.FileKind, bool, error) { + if relative != "" { + return kind, false, nil + } + switch ClassifyPlatformLayer(entry.Name()) { + case ControlSecrets: + if !entry.IsDir() { + return kind, false, fmt.Errorf("repository: control %q is not a directory", entry.Name()) + } + return deployment.FileSecret, false, nil + case ControlIgnoredUnderscore: + return kind, true, nil + default: + return kind, false, nil + } +} + +func (w *layerWalker) visit(path string, entry os.DirEntry, kind deployment.FileKind) error { + target, err := w.target(path, kind) + if err != nil { + return err + } + if entry.IsDir() { + w.view.dirs[target] = target != "" + return w.walk(path, kind) + } + if !entry.Type().IsRegular() { + return fmt.Errorf("repository: non-regular source entry %q", filepath.Join(w.relative, path)) + } + info, err := entry.Info() + if err != nil { + return err + } + candidate := Candidate{ + Scope: w.scope, Layer: w.layer, Kind: kind, + SourceRepoPath: filepath.Join(w.relative, path), SourceAbsPath: filepath.Join(w.absolute, path), + ExecutableBits: info.Mode() & 0o111, + } + existing, ok := w.view.files[target] + if ok && existing.Kind != kind { + return fmt.Errorf("repository: ordinary and secret sources collide at %q", target) + } + w.view.files[target] = candidate + return nil +} + +func (w *layerWalker) target(path string, kind deployment.FileKind) (string, error) { + if kind != deployment.FileSecret { + return path, nil + } + target := strings.TrimPrefix(strings.TrimPrefix(path, "_secrets"), "/") + if w.scope.IsRoot() { + first := strings.Split(target, "/")[0] + representable := strings.HasPrefix(first, ".") || + (!strings.Contains(target, "/") && !strings.HasPrefix(first, "_")) + if !representable { + return "", fmt.Errorf("repository: root secret target %q is not representable at the root layer", target) + } + } + return target, nil +} diff --git a/internal/repository/overlay_test.go b/internal/repository/overlay_test.go new file mode 100644 index 0000000..5912c8a --- /dev/null +++ b/internal/repository/overlay_test.go @@ -0,0 +1,236 @@ +package repository + +import ( + "io/fs" + "os" + "path/filepath" + "testing" + + "github.com/alyraffauf/cattery/internal/deployment" +) + +func TestRepositoryOverlay(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"base only", testOverlayBaseOnly}, + {"platform replaces file", testOverlayFileReplaced}, + {"platform adds paths", testOverlayAdditions}, + {"platform dir replaces file", testOverlayDirReplacesFile}, + {"empty platform dir replaces file", testOverlayEmptyDirReplacesFile}, + {"platform file replaces dir", testOverlayFileReplacesDir}, + {"recursive merge", testOverlayRecursiveMerge}, + {"inactive layers ignored", testOverlayInactiveLayers}, + {"executable bits carried", testOverlayExecutableBits}, + {"malformed layers rejected", testOverlayMalformed}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +func testOverlayBaseOnly(t *testing.T) { + root := t.TempDir() + records, err := resolvePaths(root, deployment.LayerDarwin, ".config/a", "Brewfile") + if err != nil { + t.Fatal(err) + } + assertRecords(t, records, wantRecords{root: root, records: []wantRecord{ + {repoPath: ".config/a", target: ".config/a"}, + {repoPath: "Brewfile", target: "Brewfile"}, + }}) +} + +func testOverlayFileReplaced(t *testing.T) { + root := t.TempDir() + records, err := resolvePaths(root, deployment.LayerDarwin, + ".config/ghostty/config", "_darwin/.config/ghostty/config") + if err != nil { + t.Fatal(err) + } + assertRecords(t, records, wantRecords{root: root, records: []wantRecord{ + {repoPath: "_darwin/.config/ghostty/config", target: ".config/ghostty/config", layer: deployment.LayerDarwin}, + }}) +} + +func testOverlayAdditions(t *testing.T) { + root := t.TempDir() + records, err := resolvePaths(root, deployment.LayerDarwin, + ".config/base", "_darwin/.config/extra", "_darwin/Library/app/init") + if err != nil { + t.Fatal(err) + } + assertRecords(t, records, wantRecords{root: root, records: []wantRecord{ + {repoPath: ".config/base", target: ".config/base"}, + {repoPath: "_darwin/.config/extra", target: ".config/extra", layer: deployment.LayerDarwin}, + {repoPath: "_darwin/Library/app/init", target: "Library/app/init", layer: deployment.LayerDarwin}, + }}) +} + +func testOverlayDirReplacesFile(t *testing.T) { + root := t.TempDir() + records, err := resolvePaths(root, deployment.LayerDarwin, "readme", "_darwin/readme/notes.txt") + if err != nil { + t.Fatal(err) + } + assertRecords(t, records, wantRecords{root: root, records: []wantRecord{ + {repoPath: "_darwin/readme/notes.txt", target: "readme/notes.txt", layer: deployment.LayerDarwin}, + }}) +} + +func testOverlayEmptyDirReplacesFile(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "_darwin", "app"), 0o755); err != nil { + t.Fatal(err) + } + records, err := resolvePaths(root, deployment.LayerDarwin, "app") + if err != nil { + t.Fatal(err) + } + assertRecords(t, records, wantRecords{root: root, records: nil}) +} + +func testOverlayFileReplacesDir(t *testing.T) { + root := t.TempDir() + records, err := resolvePaths(root, deployment.LayerDarwin, "bin/tool", "bin/helper", "_darwin/bin") + if err != nil { + t.Fatal(err) + } + assertRecords(t, records, wantRecords{root: root, records: []wantRecord{ + {repoPath: "_darwin/bin", target: "bin", layer: deployment.LayerDarwin}, + }}) +} + +func testOverlayRecursiveMerge(t *testing.T) { + root := t.TempDir() + records, err := resolvePaths(root, deployment.LayerDarwin, + ".config/a", ".config/shared", "_darwin/.config/b", "_darwin/.config/shared") + if err != nil { + t.Fatal(err) + } + assertRecords(t, records, wantRecords{root: root, records: []wantRecord{ + {repoPath: ".config/a", target: ".config/a"}, + {repoPath: "_darwin/.config/b", target: ".config/b", layer: deployment.LayerDarwin}, + {repoPath: "_darwin/.config/shared", target: ".config/shared", layer: deployment.LayerDarwin}, + }}) +} + +func testOverlayInactiveLayers(t *testing.T) { + root := t.TempDir() + paths := []string{".config/a", "_linux/.config/b", "_darwin/.config/c"} + darwinRecords, err := resolvePaths(root, deployment.LayerDarwin, paths...) + if err != nil { + t.Fatal(err) + } + assertRecords(t, darwinRecords, wantRecords{root: root, records: []wantRecord{ + {repoPath: ".config/a", target: ".config/a"}, + {repoPath: "_darwin/.config/c", target: ".config/c", layer: deployment.LayerDarwin}, + }}) + linuxRecords, err := resolvePaths(root, deployment.LayerLinux, paths...) + if err != nil { + t.Fatal(err) + } + assertRecords(t, linuxRecords, wantRecords{root: root, records: []wantRecord{ + {repoPath: ".config/a", target: ".config/a"}, + {repoPath: "_linux/.config/b", target: ".config/b", layer: deployment.LayerLinux}, + }}) +} + +func testOverlayExecutableBits(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "_darwin", "bin", "tool"), 0o755) + records, err := resolvePaths(root, deployment.LayerDarwin, "plain") + if err != nil { + t.Fatal(err) + } + assertRecords(t, records, wantRecords{root: root, records: []wantRecord{ + {repoPath: "_darwin/bin/tool", target: "bin/tool", layer: deployment.LayerDarwin, exec: 0o111}, + {repoPath: "plain", target: "plain"}, + }}) +} + +func testOverlayMalformed(t *testing.T) { + fileLayer := t.TempDir() + writeFile(t, filepath.Join(fileLayer, "_darwin"), 0o644) + if _, err := resolvePaths(fileLayer, deployment.LayerDarwin); err == nil { + t.Fatal("file _darwin layer was accepted") + } + symlinkLayer := t.TempDir() + writeFile(t, filepath.Join(symlinkLayer, "_darwin", "target"), 0o644) + if err := os.MkdirAll(filepath.Join(symlinkLayer, "_darwin", ".config"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink("target", filepath.Join(symlinkLayer, "_darwin", ".config", "live")); err != nil { + t.Fatal(err) + } + if _, err := resolvePaths(symlinkLayer, deployment.LayerDarwin); err == nil { + t.Fatal("symlink inside a platform layer was accepted") + } +} + +// resolvePaths materializes files beneath root, scans, and resolves platform. +func resolvePaths(root string, platform deployment.Layer, paths ...string) ([]deployment.ManagedFile, error) { + for _, path := range paths { + if err := os.MkdirAll(filepath.Dir(filepath.Join(root, path)), 0o755); err != nil { + return nil, err + } + if err := os.WriteFile(filepath.Join(root, path), nil, 0o644); err != nil { + return nil, err + } + } + result, err := Scan(root) + if err != nil { + return nil, err + } + return ResolvePlatform(root, result, platform) +} + +// wantRecord describes one expected managed-file record compactly. +type wantRecord struct { + scope deployment.Scope + layer deployment.Layer + kind deployment.FileKind + repoPath string + target string + exec fs.FileMode +} + +// newRecord builds the expected record for a repo-relative path. +func newRecord(root string, want wantRecord) deployment.ManagedFile { + layer := want.layer + if layer == "" { + layer = deployment.LayerBase + } + kind := want.kind + if kind == "" { + kind = deployment.FileOrdinary + } + return deployment.ManagedFile{ + Scope: want.scope, Layer: layer, Kind: kind, + SourceAbsolutePath: filepath.Join(root, want.repoPath), + SourceRepositoryPath: want.repoPath, + TargetRelativePath: want.target, + SourceExecutableBits: want.exec, + } +} + +// wantRecords bundles the expected records with their repository root. +type wantRecords struct { + root string + records []wantRecord +} + +// assertRecords verifies got against the expected records. +func assertRecords(t *testing.T, got []deployment.ManagedFile, want wantRecords) { + t.Helper() + if len(got) != len(want.records) { + t.Fatalf("records = %d, want %d", len(got), len(want.records)) + } + for index, wantRecord := range want.records { + expected := newRecord(want.root, wantRecord) + if got[index] != expected { + t.Fatalf("record %d = %+v, want %+v", index, got[index], expected) + } + } +} diff --git a/internal/repository/secrets_test.go b/internal/repository/secrets_test.go new file mode 100644 index 0000000..842abb4 --- /dev/null +++ b/internal/repository/secrets_test.go @@ -0,0 +1,148 @@ +package repository + +import ( + "path/filepath" + "testing" + + "github.com/alyraffauf/cattery/internal/deployment" +) + +func TestSecretOverlay(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"base secret", testSecretBase}, + {"group secret unrestricted", testSecretGroup}, + {"platform secret replaces ordinary", testSecretPlatformReplacesOrdinary}, + {"platform ordinary replaces secret", testSecretOrdinaryReplacesSecret}, + {"same-layer kind collision", testSecretSameLayerCollision}, + {"platform same-layer kind collision", testSecretPlatformCollision}, + {"root secret representability", testSecretRootRepresentability}, + {"platform secret representability", testSecretPlatformRepresentability}, + {"inactive layer secrets", testSecretInactiveLayers}, + {"secret executable bits", testSecretExecutableBits}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +func testSecretBase(t *testing.T) { + root := t.TempDir() + records, err := resolvePaths(root, deployment.LayerDarwin, "_secrets/.config/app/token") + if err != nil { + t.Fatal(err) + } + assertRecords(t, records, wantRecords{root: root, records: []wantRecord{ + {repoPath: "_secrets/.config/app/token", target: ".config/app/token", kind: deployment.FileSecret}, + }}) +} + +func testSecretGroup(t *testing.T) { + root := t.TempDir() + records, err := resolvePaths(root, deployment.LayerDarwin, + "atuin/_secrets/bin/token", "atuin/_secrets/x") + if err != nil { + t.Fatal(err) + } + assertRecords(t, records, wantRecords{root: root, records: []wantRecord{ + {scope: deployment.NewScope("atuin"), repoPath: "atuin/_secrets/bin/token", target: "bin/token", kind: deployment.FileSecret}, + {scope: deployment.NewScope("atuin"), repoPath: "atuin/_secrets/x", target: "x", kind: deployment.FileSecret}, + }}) +} + +func testSecretPlatformReplacesOrdinary(t *testing.T) { + root := t.TempDir() + records, err := resolvePaths(root, deployment.LayerDarwin, + ".config/app/credentials", "_darwin/_secrets/.config/app/credentials") + if err != nil { + t.Fatal(err) + } + assertRecords(t, records, wantRecords{root: root, records: []wantRecord{ + {repoPath: "_darwin/_secrets/.config/app/credentials", target: ".config/app/credentials", layer: deployment.LayerDarwin, kind: deployment.FileSecret}, + }}) +} + +func testSecretOrdinaryReplacesSecret(t *testing.T) { + root := t.TempDir() + records, err := resolvePaths(root, deployment.LayerDarwin, "_secrets/token", "_darwin/token") + if err != nil { + t.Fatal(err) + } + assertRecords(t, records, wantRecords{root: root, records: []wantRecord{ + {repoPath: "_darwin/token", target: "token", layer: deployment.LayerDarwin}, + }}) +} + +func testSecretSameLayerCollision(t *testing.T) { + root := t.TempDir() + if _, err := resolvePaths(root, deployment.LayerDarwin, ".config/x", "_secrets/.config/x"); err == nil { + t.Fatal("same-layer ordinary/secret collision was accepted") + } +} + +func testSecretPlatformCollision(t *testing.T) { + root := t.TempDir() + if _, err := resolvePaths(root, deployment.LayerDarwin, "_darwin/.config/x", "_darwin/_secrets/.config/x"); err == nil { + t.Fatal("same-layer platform ordinary/secret collision was accepted") + } +} + +func testSecretRootRepresentability(t *testing.T) { + root := t.TempDir() + if _, err := resolvePaths(root, deployment.LayerDarwin, "_secrets/bin/token"); err == nil { + t.Fatal("unrepresentable root secret target was accepted") + } + underscoreRoot := t.TempDir() + if _, err := resolvePaths(underscoreRoot, deployment.LayerDarwin, "_secrets/_token"); err == nil { + t.Fatal("underscore root secret target was accepted") + } + validRoot := t.TempDir() + records, err := resolvePaths(validRoot, deployment.LayerDarwin, "_secrets/token") + if err != nil { + t.Fatal(err) + } + assertRecords(t, records, wantRecords{root: validRoot, records: []wantRecord{ + {repoPath: "_secrets/token", target: "token", kind: deployment.FileSecret}, + }}) +} + +func testSecretPlatformRepresentability(t *testing.T) { + root := t.TempDir() + if _, err := resolvePaths(root, deployment.LayerDarwin, "_darwin/_secrets/bin/token"); err == nil { + t.Fatal("unrepresentable platform secret target was accepted") + } +} + +func testSecretInactiveLayers(t *testing.T) { + root := t.TempDir() + paths := []string{"_secrets/base", "_linux/_secrets/x"} + darwinRecords, err := resolvePaths(root, deployment.LayerDarwin, paths...) + if err != nil { + t.Fatal(err) + } + assertRecords(t, darwinRecords, wantRecords{root: root, records: []wantRecord{ + {repoPath: "_secrets/base", target: "base", kind: deployment.FileSecret}, + }}) + linuxRecords, err := resolvePaths(root, deployment.LayerLinux, paths...) + if err != nil { + t.Fatal(err) + } + assertRecords(t, linuxRecords, wantRecords{root: root, records: []wantRecord{ + {repoPath: "_secrets/base", target: "base", kind: deployment.FileSecret}, + {repoPath: "_linux/_secrets/x", target: "x", layer: deployment.LayerLinux, kind: deployment.FileSecret}, + }}) +} + +func testSecretExecutableBits(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "_secrets", ".config", "app", "tool"), 0o755) + records, err := resolvePaths(root, deployment.LayerDarwin) + if err != nil { + t.Fatal(err) + } + assertRecords(t, records, wantRecords{root: root, records: []wantRecord{ + {repoPath: "_secrets/.config/app/tool", target: ".config/app/tool", kind: deployment.FileSecret, exec: 0o111}, + }}) +}