diff --git a/internal/deployment/alias.go b/internal/deployment/alias.go new file mode 100644 index 0000000..abc732b --- /dev/null +++ b/internal/deployment/alias.go @@ -0,0 +1,97 @@ +package deployment + +import "fmt" + +// HookPhase names the position a hook runs in relative to file deployment. The +// apply orchestrator, not this type, owns final ordering between phases. +type HookPhase string + +const ( + HookBefore HookPhase = "before" + HookAfter HookPhase = "after" +) + +// ParseHookPhase converts a raw string into a HookPhase, rejecting unknown +// values. +func ParseHookPhase(value string) (HookPhase, error) { + phase := HookPhase(value) + if !phase.Valid() { + return "", fmt.Errorf("deployment: unknown hook phase %q", value) + } + return phase, nil +} + +// Valid reports whether phase is one of the supported constants. +func (p HookPhase) Valid() bool { + switch p { + case HookBefore, HookAfter: + return true + } + return false +} + +// Alias describes one explicitly declared symlink pointing at a canonical +// target. Aliases are the only symlinks Cattery deploys; they are never the +// primary deployment strategy. +type Alias struct { + Scope Scope + Platform string + CanonicalTargetRelativePath string + AliasRelativePath string +} + +// NewAlias validates candidate field-by-field and returns it on success. +func NewAlias(candidate Alias) (Alias, error) { + if err := validateAlias(candidate); err != nil { + return Alias{}, err + } + return candidate, nil +} + +func validateAlias(alias Alias) error { + if alias.AliasRelativePath == "" { + return fmt.Errorf("deployment: alias has empty alias relative path") + } + if alias.Platform == "" { + return fmt.Errorf("deployment: alias %q has empty platform", alias.AliasRelativePath) + } + if alias.CanonicalTargetRelativePath == "" { + return fmt.Errorf("deployment: alias %q has empty canonical target", alias.AliasRelativePath) + } + return nil +} + +// Hook describes one validated hook descriptor. Final execution order is owned +// by the apply orchestrator; this descriptor carries identity and absolute +// location only. +type Hook struct { + Scope Scope + Phase HookPhase + Name string + AbsolutePath string + RepositoryPath string +} + +// NewHook validates candidate field-by-field and returns it on success. +func NewHook(candidate Hook) (Hook, error) { + if err := validateHook(candidate); err != nil { + return Hook{}, err + } + return candidate, nil +} + +func validateHook(hook Hook) error { + if !hook.Phase.Valid() { + return fmt.Errorf("deployment: hook has invalid phase") + } + if hook.Name == "" { + return fmt.Errorf("deployment: hook has empty name") + } + if hook.AbsolutePath == "" { + return fmt.Errorf("deployment: hook %q missing absolute path", hook.Name) + } + if hook.RepositoryPath == "" { + return fmt.Errorf("deployment: hook %q missing repository path", hook.Name) + } + return nil +} diff --git a/internal/deployment/alias_test.go b/internal/deployment/alias_test.go new file mode 100644 index 0000000..e2a3abe --- /dev/null +++ b/internal/deployment/alias_test.go @@ -0,0 +1,140 @@ +package deployment + +import "testing" + +func TestAliasContract(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"accepts valid alias", testAcceptsValidAlias}, + {"rejects empty platform", testAliasRejectsPlatform}, + {"rejects empty canonical", testAliasRejectsCanonical}, + {"rejects empty alias path", testAliasRejectsAliasPath}, + {"accepts valid hook", testAcceptsValidHook}, + {"rejects hook invalid phase", testHookRejectsPhase}, + {"rejects hook empty name", testHookRejectsName}, + {"rejects hook empty absolute", testHookRejectsAbsolute}, + {"rejects hook empty repo path", testHookRejectsRepoPath}, + {"parses hook phases", testParsesHookPhases}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +func validAliasCandidate() Alias { + return Alias{ + Scope: NewScope("atuin"), + Platform: "linux", + CanonicalTargetRelativePath: ".config/atuin/config.toml", + AliasRelativePath: ".config/alt-config.toml", + } +} + +func testAcceptsValidAlias(t *testing.T) { + candidate := validAliasCandidate() + alias, err := NewAlias(candidate) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if alias != candidate { + t.Fatal("valid candidate must round-trip") + } +} + +func testAliasRejectsPlatform(t *testing.T) { + candidate := validAliasCandidate() + candidate.Platform = "" + if _, err := NewAlias(candidate); err == nil { + t.Fatal("expected error for empty platform") + } +} + +func testAliasRejectsCanonical(t *testing.T) { + candidate := validAliasCandidate() + candidate.CanonicalTargetRelativePath = "" + if _, err := NewAlias(candidate); err == nil { + t.Fatal("expected error for empty canonical target") + } +} + +func testAliasRejectsAliasPath(t *testing.T) { + candidate := validAliasCandidate() + candidate.AliasRelativePath = "" + if _, err := NewAlias(candidate); err == nil { + t.Fatal("expected error for empty alias path") + } +} + +func validHookCandidate() Hook { + return Hook{ + Scope: NewScope("atuin"), + Phase: HookBefore, + Name: "install", + AbsolutePath: "/repo/_hooks/install.sh", + RepositoryPath: "_hooks/install.sh", + } +} + +func testAcceptsValidHook(t *testing.T) { + candidate := validHookCandidate() + hook, err := NewHook(candidate) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if hook != candidate { + t.Fatal("valid candidate must round-trip") + } +} + +func testHookRejectsPhase(t *testing.T) { + candidate := validHookCandidate() + candidate.Phase = HookPhase("during") + if _, err := NewHook(candidate); err == nil { + t.Fatal("expected error for invalid phase") + } +} + +func testHookRejectsName(t *testing.T) { + candidate := validHookCandidate() + candidate.Name = "" + if _, err := NewHook(candidate); err == nil { + t.Fatal("expected error for empty name") + } +} + +func testHookRejectsAbsolute(t *testing.T) { + candidate := validHookCandidate() + candidate.AbsolutePath = "" + if _, err := NewHook(candidate); err == nil { + t.Fatal("expected error for empty absolute path") + } +} + +func testHookRejectsRepoPath(t *testing.T) { + candidate := validHookCandidate() + candidate.RepositoryPath = "" + if _, err := NewHook(candidate); err == nil { + t.Fatal("expected error for empty repository path") + } +} + +func testParsesHookPhases(t *testing.T) { + scenarios := []struct { + input string + want HookPhase + }{ + {"before", HookBefore}, + {"after", HookAfter}, + } + for _, scenario := range scenarios { + got, err := ParseHookPhase(scenario.input) + if err != nil { + t.Fatalf("ParseHookPhase(%q) err = %v", scenario.input, err) + } + if got != scenario.want { + t.Fatalf("ParseHookPhase(%q) = %v, want %v", scenario.input, got, scenario.want) + } + } +} diff --git a/internal/deployment/file.go b/internal/deployment/file.go new file mode 100644 index 0000000..5b15b97 --- /dev/null +++ b/internal/deployment/file.go @@ -0,0 +1,75 @@ +package deployment + +import ( + "fmt" + "io/fs" +) + +// FileKind distinguishes ordinary deployable files from SOPS-encrypted +// secrets. Only the kind changes; target and repository paths are unaffected. +type FileKind string + +const ( + FileOrdinary FileKind = "ordinary" + FileSecret FileKind = "secret" +) + +// ParseFileKind converts a raw string into a FileKind, rejecting unknown +// values. +func ParseFileKind(value string) (FileKind, error) { + kind := FileKind(value) + if !kind.Valid() { + return "", fmt.Errorf("deployment: unknown file kind %q", value) + } + return kind, nil +} + +// Valid reports whether kind is one of the supported constants. +func (k FileKind) Valid() bool { + switch k { + case FileOrdinary, FileSecret: + return true + } + return false +} + +// ManagedFile describes one deployable source file compiled from a repository. +// SourceRepositoryPath is repository-relative; TargetRelativePath is +// HOME-relative; SourceExecutableBits records the executable bits Cattery +// must reproduce on the target. +type ManagedFile struct { + Scope Scope + Layer Layer + Kind FileKind + SourceAbsolutePath string + SourceRepositoryPath string + TargetRelativePath string + SourceExecutableBits fs.FileMode +} + +// NewManagedFile validates candidate field-by-field and returns it on success. +func NewManagedFile(candidate ManagedFile) (ManagedFile, error) { + if err := validateFile(candidate); err != nil { + return ManagedFile{}, err + } + return candidate, nil +} + +func validateFile(file ManagedFile) error { + if file.TargetRelativePath == "" { + return fmt.Errorf("deployment: managed file has empty target relative path") + } + if !file.Layer.Valid() { + return fmt.Errorf("deployment: file %q has invalid layer", file.TargetRelativePath) + } + if !file.Kind.Valid() { + return fmt.Errorf("deployment: file %q has invalid kind", file.TargetRelativePath) + } + if file.SourceAbsolutePath == "" { + return fmt.Errorf("deployment: file %q missing source absolute path", file.TargetRelativePath) + } + if file.SourceRepositoryPath == "" { + return fmt.Errorf("deployment: file %q missing repository path", file.TargetRelativePath) + } + return nil +} diff --git a/internal/deployment/file_test.go b/internal/deployment/file_test.go new file mode 100644 index 0000000..1070956 --- /dev/null +++ b/internal/deployment/file_test.go @@ -0,0 +1,103 @@ +package deployment + +import "testing" + +func TestManagedFileContract(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"accepts valid file", testAcceptsValidFile}, + {"rejects invalid layer", testFileRejectsLayer}, + {"rejects invalid kind", testFileRejectsKind}, + {"rejects empty source absolute", testFileRejectsSourceAbsolute}, + {"rejects empty repository path", testFileRejectsRepositoryPath}, + {"rejects empty target path", testFileRejectsEmptyTarget}, + {"parses file kinds", testParsesFileKinds}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +func validFileCandidate() ManagedFile { + return ManagedFile{ + Scope: NewScope("atuin"), + Layer: LayerBase, + Kind: FileOrdinary, + SourceAbsolutePath: "/repo/atuin/config.toml", + SourceRepositoryPath: "atuin/config.toml", + TargetRelativePath: ".config/atuin/config.toml", + SourceExecutableBits: 0o755, + } +} + +func testAcceptsValidFile(t *testing.T) { + candidate := validFileCandidate() + file, err := NewManagedFile(candidate) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if file != candidate { + t.Fatal("valid candidate must round-trip") + } +} + +func testFileRejectsLayer(t *testing.T) { + candidate := validFileCandidate() + candidate.Layer = Layer("windows") + if _, err := NewManagedFile(candidate); err == nil { + t.Fatal("expected error for invalid layer") + } +} + +func testFileRejectsKind(t *testing.T) { + candidate := validFileCandidate() + candidate.Kind = FileKind("encrypted") + if _, err := NewManagedFile(candidate); err == nil { + t.Fatal("expected error for invalid kind") + } +} + +func testFileRejectsSourceAbsolute(t *testing.T) { + candidate := validFileCandidate() + candidate.SourceAbsolutePath = "" + if _, err := NewManagedFile(candidate); err == nil { + t.Fatal("expected error for empty source absolute path") + } +} + +func testFileRejectsRepositoryPath(t *testing.T) { + candidate := validFileCandidate() + candidate.SourceRepositoryPath = "" + if _, err := NewManagedFile(candidate); err == nil { + t.Fatal("expected error for empty repository path") + } +} + +func testFileRejectsEmptyTarget(t *testing.T) { + candidate := validFileCandidate() + candidate.TargetRelativePath = "" + if _, err := NewManagedFile(candidate); err == nil { + t.Fatal("expected error for empty target path") + } +} + +func testParsesFileKinds(t *testing.T) { + scenarios := []struct { + input string + want FileKind + }{ + {"ordinary", FileOrdinary}, + {"secret", FileSecret}, + } + for _, scenario := range scenarios { + got, err := ParseFileKind(scenario.input) + if err != nil { + t.Fatalf("ParseFileKind(%q) err = %v", scenario.input, err) + } + if got != scenario.want { + t.Fatalf("ParseFileKind(%q) = %v, want %v", scenario.input, got, scenario.want) + } + } +} diff --git a/internal/deployment/plan.go b/internal/deployment/plan.go new file mode 100644 index 0000000..f421aaf --- /dev/null +++ b/internal/deployment/plan.go @@ -0,0 +1,63 @@ +package deployment + +// Plan is the immutable, validated deployment plan compiled from a repository +// for one platform. Slice fields are defensively copied on construction and +// on every read, so callers can never mutate a Plan through slices they held +// before construction or hold after an accessor returns. +type Plan struct { + RepositoryRoot string + Platform string + Groups []string + Files []ManagedFile + Aliases []Alias + Hooks []Hook +} + +// NewPlan constructs a Plan from candidate, defensively copying every input +// slice so the caller's source slices cannot mutate the plan later. +func NewPlan(candidate Plan) Plan { + return Plan{ + RepositoryRoot: candidate.RepositoryRoot, + Platform: candidate.Platform, + Groups: copyStrings(candidate.Groups), + Files: copyFiles(candidate.Files), + Aliases: copyAliases(candidate.Aliases), + Hooks: copyHooks(candidate.Hooks), + } +} + +// AllGroups returns a defensive copy of the plan's group list. +func (p Plan) AllGroups() []string { + return copyStrings(p.Groups) +} + +// AllFiles returns a defensive copy of the plan's file list. +func (p Plan) AllFiles() []ManagedFile { + return copyFiles(p.Files) +} + +// AllAliases returns a defensive copy of the plan's alias list. +func (p Plan) AllAliases() []Alias { + return copyAliases(p.Aliases) +} + +// AllHooks returns a defensive copy of the plan's hook list. +func (p Plan) AllHooks() []Hook { + return copyHooks(p.Hooks) +} + +func copyStrings(items []string) []string { + return append([]string(nil), items...) +} + +func copyFiles(items []ManagedFile) []ManagedFile { + return append([]ManagedFile(nil), items...) +} + +func copyAliases(items []Alias) []Alias { + return append([]Alias(nil), items...) +} + +func copyHooks(items []Hook) []Hook { + return append([]Hook(nil), items...) +} diff --git a/internal/deployment/plan_test.go b/internal/deployment/plan_test.go new file mode 100644 index 0000000..6f50ca4 --- /dev/null +++ b/internal/deployment/plan_test.go @@ -0,0 +1,113 @@ +package deployment + +import ( + "io/fs" + "testing" +) + +func TestPlanContract(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"constructor copies caller slices", testPlanCopiesInputs}, + {"accessor copy cannot mutate plan", testAccessorCopyIsolation}, + {"zero plan yields nil accessors", testZeroPlanAccessors}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +func samplePlanCandidate() Plan { + return Plan{ + RepositoryRoot: "/repo", + Platform: "linux", + Groups: []string{"atuin", "zsh"}, + Files: []ManagedFile{ + { + Scope: NewScope("atuin"), Layer: LayerBase, Kind: FileOrdinary, + SourceAbsolutePath: "/repo/atuin/c", SourceRepositoryPath: "atuin/c", + TargetRelativePath: ".config/atuin/c", SourceExecutableBits: fs.FileMode(0o644), + }, + }, + Aliases: []Alias{ + { + Scope: NewScope(""), Platform: "linux", + CanonicalTargetRelativePath: ".bashrc", AliasRelativePath: ".bash_aliases", + }, + }, + Hooks: []Hook{ + { + Scope: NewScope(""), Phase: HookBefore, Name: "install", + AbsolutePath: "/repo/_hooks/install.sh", RepositoryPath: "_hooks/install.sh", + }, + }, + } +} + +func testPlanCopiesInputs(t *testing.T) { + candidate := samplePlanCandidate() + groups := candidate.Groups + files := candidate.Files + aliases := candidate.Aliases + hooks := candidate.Hooks + plan := NewPlan(candidate) + groups = append(groups, " mutated") + files = append(files, ManagedFile{TargetRelativePath: "x"}) + aliases = append(aliases, Alias{AliasRelativePath: "x"}) + hooks = append(hooks, Hook{Name: "x"}) + if len(plan.AllGroups()) != 2 { + t.Fatalf("groups leaked: %v", plan.AllGroups()) + } + if len(plan.AllFiles()) != 1 { + t.Fatalf("files leaked: %v", plan.AllFiles()) + } + if len(plan.AllAliases()) != 1 { + t.Fatalf("aliases leaked: %v", plan.AllAliases()) + } + if len(plan.AllHooks()) != 1 { + t.Fatalf("hooks leaked: %v", plan.AllHooks()) + } +} + +func testAccessorCopyIsolation(t *testing.T) { + plan := NewPlan(samplePlanCandidate()) + groups := plan.AllGroups() + files := plan.AllFiles() + aliases := plan.AllAliases() + hooks := plan.AllHooks() + groups[0] = "corrupted" + files[0] = ManagedFile{TargetRelativePath: "corrupted"} + aliases[0] = Alias{AliasRelativePath: "corrupted"} + hooks[0] = Hook{Name: "corrupted"} + again := NewPlan(samplePlanCandidate()) + if plan.AllGroups()[0] == "corrupted" || again.AllGroups()[0] == "corrupted" { + t.Fatal("accessor copy leaked into plan") + } + if plan.AllFiles()[0].TargetRelativePath == "corrupted" { + t.Fatal("file accessor copy leaked into plan") + } + if plan.AllAliases()[0].AliasRelativePath == "corrupted" { + t.Fatal("alias accessor copy leaked into plan") + } + if plan.AllHooks()[0].Name == "corrupted" { + t.Fatal("hook accessor copy leaked into plan") + } +} + +func testZeroPlanAccessors(t *testing.T) { + var plan Plan + if got := plan.AllGroups(); got != nil { + t.Fatalf("zero plan groups = %v, want nil", got) + } + if got := plan.AllFiles(); got != nil { + t.Fatalf("zero plan files = %v, want nil", got) + } + if got := plan.AllAliases(); got != nil { + t.Fatalf("zero plan aliases = %v, want nil", got) + } + if got := plan.AllHooks(); got != nil { + t.Fatalf("zero plan hooks = %v, want nil", got) + } +} diff --git a/internal/deployment/scope.go b/internal/deployment/scope.go new file mode 100644 index 0000000..793822f --- /dev/null +++ b/internal/deployment/scope.go @@ -0,0 +1,49 @@ +package deployment + +import "fmt" + +// Scope names the repository region a deployable entry belongs to. An empty +// Group identifies the root scope; any other Group names a top-level group +// directory beneath the repository root. +type Scope struct { + Group string +} + +// NewScope constructs a Scope. Pass an empty group for the root scope. +func NewScope(group string) Scope { + return Scope{Group: group} +} + +// IsRoot reports whether this scope is the ungrouped repository root. +func (s Scope) IsRoot() bool { + return s.Group == "" +} + +// Layer names the platform stratum a source entry belongs to. The base layer +// applies on every runtime; darwin and linux overlays replace or extend it on +// the matching platform. +type Layer string + +const ( + LayerBase Layer = "base" + LayerDarwin Layer = "darwin" + LayerLinux Layer = "linux" +) + +// ParseLayer converts a raw string into a Layer, rejecting unknown values. +func ParseLayer(value string) (Layer, error) { + layer := Layer(value) + if !layer.Valid() { + return "", fmt.Errorf("deployment: unknown layer %q", value) + } + return layer, nil +} + +// Valid reports whether layer is one of the supported constants. +func (l Layer) Valid() bool { + switch l { + case LayerBase, LayerDarwin, LayerLinux: + return true + } + return false +} diff --git a/internal/deployment/scope_test.go b/internal/deployment/scope_test.go new file mode 100644 index 0000000..c3411b2 --- /dev/null +++ b/internal/deployment/scope_test.go @@ -0,0 +1,71 @@ +package deployment + +import "testing" + +func TestScopeContract(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"root scope is explicit", testRootScopeIsExplicit}, + {"group scope preserved", testGroupScopePreserved}, + {"rejects unknown layer", testRejectsUnknownLayer}, + {"accepts known layers", testAcceptsKnownLayers}, + {"layer valid flag", testLayerValidFlag}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +func testRootScopeIsExplicit(t *testing.T) { + root := NewScope("") + if !root.IsRoot() { + t.Fatal("empty group must identify the root scope") + } +} + +func testGroupScopePreserved(t *testing.T) { + scope := NewScope("atuin") + if scope.Group != "atuin" { + t.Fatalf("group = %q", scope.Group) + } + if scope.IsRoot() { + t.Fatal("non-empty group must not report as root") + } +} + +func testRejectsUnknownLayer(t *testing.T) { + if _, err := ParseLayer("windows"); err == nil { + t.Fatal("expected error for unknown layer") + } +} + +func testAcceptsKnownLayers(t *testing.T) { + scenarios := []struct { + input string + want Layer + }{ + {"base", LayerBase}, + {"darwin", LayerDarwin}, + {"linux", LayerLinux}, + } + for _, scenario := range scenarios { + got, err := ParseLayer(scenario.input) + if err != nil { + t.Fatalf("ParseLayer(%q) err = %v", scenario.input, err) + } + if got != scenario.want { + t.Fatalf("ParseLayer(%q) = %v, want %v", scenario.input, got, scenario.want) + } + } +} + +func testLayerValidFlag(t *testing.T) { + if !LayerBase.Valid() { + t.Fatal("LayerBase must be valid") + } + if Layer("windows").Valid() { + t.Fatal("unknown layer must not be valid") + } +}