diff --git a/internal/application/validate/service.go b/internal/application/validate/service.go new file mode 100644 index 0000000..b919bae --- /dev/null +++ b/internal/application/validate/service.go @@ -0,0 +1,185 @@ +package validate + +import ( + "context" + "encoding/json" + "os" + "sort" + + "github.com/alyraffauf/cattery/internal/deployment" + "github.com/alyraffauf/cattery/internal/failure" + "github.com/alyraffauf/cattery/internal/repository" + "github.com/alyraffauf/cattery/internal/selection" +) + +// Service performs one repository validation against the injectable source +// and compiler ports. Construction is side-effect-free: repository scanning +// and compilation happen only inside Validate. +type Service struct { + source RepositorySource + protectedTrees []string + compiler Compiler +} + +// NewService constructs the validation service bound to the dependencies. +func NewService(dependencies Dependencies) *Service { + return &Service{ + source: dependencies.RepositorySource, + protectedTrees: dependencies.ProtectedTrees, + compiler: dependencies.Compiler, + } +} + +// Validate resolves the canonical repository, compiles and validates the full +// Linux and Darwin plans, checks every secret's JSON storage shape, and +// reports counts of the selected scopes. Global validation covers every scope +// on both platforms before the selection filters the reported counts. +func (service *Service) Validate(ctx context.Context, request Request) (Result, error) { + if err := ctx.Err(); err != nil { + return Result{}, err + } + identity, err := service.resolve(request.Repository) + if err != nil { + return Result{}, err + } + linux, darwin, err := service.fullPlans(identity) + if err != nil { + return Result{}, err + } + chosen, err := selection.CompiledOnly(linux.AllGroups(), request.Groups) + if err != nil { + return Result{}, failure.New(failure.InvalidInput, "validate: select groups", err) + } + linux, darwin, err = service.compilePair(identity, chosen.Groups) + if err != nil { + return Result{}, err + } + return Result{Platforms: platformCounts(linux, darwin)}, nil +} + +// fullPlans compiles both full platform plans and checks every secret's JSON +// storage shape, so an invalid unselected scope cannot hide. +func (service *Service) fullPlans(identity RepositoryIdentity) (deployment.Plan, deployment.Plan, error) { + linux, darwin, err := service.compilePair(identity, nil) + if err != nil { + return deployment.Plan{}, deployment.Plan{}, err + } + if err := service.checkSecretShapes(linux, darwin); err != nil { + return deployment.Plan{}, deployment.Plan{}, err + } + return linux, darwin, nil +} + +// resolve maps the raw repository fields onto the selection request and +// resolves the canonical pair through the injected source. +func (service *Service) resolve(input RepositoryInput) (RepositoryIdentity, error) { + identity, err := service.source.Resolve(repositoryRequest(input)) + if err != nil { + return RepositoryIdentity{}, failure.New(failure.InvalidInput, "validate: resolve repository", err) + } + return identity, nil +} + +// repositoryRequest mechanically copies the raw repository fields into the +// selection request shape. +func repositoryRequest(input RepositoryInput) selection.RepositoryRequest { + return selection.RepositoryRequest{ + RawExplicit: input.RawExplicit, + ExplicitSet: input.ExplicitSet, + RawEnv: input.RawEnv, + EnvSet: input.EnvSet, + WorkingDir: input.WorkingDir, + } +} + +// compilePair compiles and validates the platform pair, each restricted to +// the selection (nil selects everything). Compilation always validates every +// scope, selected or not. +func (service *Service) compilePair(identity RepositoryIdentity, selected []string) (deployment.Plan, deployment.Plan, error) { + linux, err := service.compile(identity, deployment.LayerLinux, selected) + if err != nil { + return deployment.Plan{}, deployment.Plan{}, err + } + darwin, err := service.compile(identity, deployment.LayerDarwin, selected) + if err != nil { + return deployment.Plan{}, deployment.Plan{}, err + } + return linux, darwin, nil +} + +// compile validates the entire repository for one platform and returns the +// plan restricted to the selection. +func (service *Service) compile(identity RepositoryIdentity, layer deployment.Layer, selected []string) (deployment.Plan, error) { + plan, err := service.compiler.Compile(repository.CompileInput{ + Platform: layer, + RepositoryRoot: identity.Root, + HomeRoot: identity.Home, + Protected: service.protectedTrees, + Selected: selected, + }) + if err != nil { + return deployment.Plan{}, failure.New(failure.InvalidInput, "validate: compile plan", err) + } + return plan, nil +} + +// checkSecretShapes rejects any empty or malformed JSON secret source in the +// full plans. This is storage-shape validation only: it never decrypts. +func (service *Service) checkSecretShapes(plans ...deployment.Plan) error { + for _, plan := range plans { + if err := service.checkPlanShapes(plan); err != nil { + return err + } + } + return nil +} + +// checkPlanShapes requires every secret source of one plan to be valid. +func (service *Service) checkPlanShapes(plan deployment.Plan) error { + for _, file := range plan.AllFiles() { + if file.Kind != deployment.FileSecret { + continue + } + if err := checkSecretShape(file.SourceAbsolutePath); err != nil { + return err + } + } + return nil +} + +// checkSecretShape requires one secret source to be nonempty valid JSON. +func checkSecretShape(path string) error { + data, err := os.ReadFile(path) + if err != nil { + return failure.New(failure.Operational, "validate: read secret "+path, err) + } + if len(data) == 0 || !json.Valid(data) { + return failure.New(failure.InvalidInput, "validate: secret "+path+" is not nonempty valid JSON", nil) + } + return nil +} + +// platformCounts projects the two compiled plans into sorted platform records. +func platformCounts(linux, darwin deployment.Plan) []PlatformCount { + records := []PlatformCount{platformRecord(darwin), platformRecord(linux)} + sort.Slice(records, func(first, second int) bool { + return records[first].Platform < records[second].Platform + }) + return records +} + +// platformRecord counts the selected scopes of one platform plan. +func platformRecord(plan deployment.Plan) PlatformCount { + record := PlatformCount{ + Platform: plan.Platform, + Files: len(plan.AllFiles()), + Aliases: len(plan.AllAliases()), + Groups: len(plan.AllGroups()), + } + for _, file := range plan.AllFiles() { + if file.Kind == deployment.FileSecret { + record.Secrets++ + } + } + return record +} diff --git a/internal/application/validate/service_test.go b/internal/application/validate/service_test.go new file mode 100644 index 0000000..0f77884 --- /dev/null +++ b/internal/application/validate/service_test.go @@ -0,0 +1,229 @@ +package validate + +import ( + "context" + "errors" + "os" + "path/filepath" + "slices" + "testing" + + "github.com/alyraffauf/cattery/internal/deployment" + "github.com/alyraffauf/cattery/internal/failure" + "github.com/alyraffauf/cattery/internal/repository" + "github.com/alyraffauf/cattery/internal/selection" +) + +func TestValidateService(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"maps raw repository fields onto the selection request", testServiceMapsRequest}, + {"reports exactly two sorted records for the selection", testServiceCounts}, + {"source failures are invalid input", testServiceSourceFailure}, + {"unknown and duplicate groups are invalid input", testServiceGroupFailures}, + {"invalid unselected scopes still fail", testServiceInvalidUnselectedScope}, + {"secrets must be nonempty valid JSON", testServiceSecretShape}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +type fixture struct { + service *Service + source *fakeSource + root string +} + +func newFixture(t *testing.T) *fixture { + t.Helper() + root := t.TempDir() + source := &fakeSource{identity: RepositoryIdentity{Root: root, Home: filepath.Join(root, "home")}} + service := NewService(Dependencies{ + RepositorySource: source, + Compiler: compileFunc(repository.Compile), + ProtectedTrees: []string{filepath.Join(root, "state")}, + }) + return &fixture{service: service, source: source, root: root} +} + +// compileFunc adapts the package compiler function to the narrow port. +type compileFunc func(repository.CompileInput) (deployment.Plan, error) + +func (adapter compileFunc) Compile(input repository.CompileInput) (deployment.Plan, error) { + return adapter(input) +} + +type fakeSource struct { + identity RepositoryIdentity + last selection.RepositoryRequest + calls int + fail error +} + +func (fake *fakeSource) Resolve(request selection.RepositoryRequest) (RepositoryIdentity, error) { + fake.calls++ + fake.last = request + if fake.fail != nil { + return RepositoryIdentity{}, fake.fail + } + return fake.identity, nil +} + +// repositoryTree creates a repository with one ordinary file and one valid +// secret per group plus a root _secrets tree. +func repositoryTree(t *testing.T, groups []string) string { + t.Helper() + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "_secrets"), 0o755); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(root, "_secrets", "root-secret"), `{"root": true}`) + for _, group := range groups { + if err := os.MkdirAll(filepath.Join(root, group), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(root, group, "_secrets"), 0o755); err != nil { + t.Fatal(err) + } + writeFile(t, filepath.Join(root, group, group+"-file"), "x") + writeFile(t, filepath.Join(root, group, "_secrets", group+"-secret"), `{"data": 1}`) + } + return root +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } +} +func assertInvalidInput(t *testing.T, err error) { + t.Helper() + if kind, matched := failure.HasKind(err); !matched || kind != failure.InvalidInput { + t.Fatalf("error = %v, want InvalidInput", err) + } +} + +func testServiceMapsRequest(t *testing.T) { + fx := newFixture(t) + request := Request{Repository: RepositoryInput{ + RawExplicit: filepath.Join(fx.root, "repo"), ExplicitSet: true, + RawEnv: "/ignored", EnvSet: true, WorkingDir: "/work", + }} + if _, err := fx.service.Validate(context.Background(), request); err != nil { + t.Fatalf("Validate: %v", err) + } + want := selection.RepositoryRequest{ + RawExplicit: filepath.Join(fx.root, "repo"), ExplicitSet: true, + RawEnv: "/ignored", EnvSet: true, WorkingDir: "/work", + } + if fx.source.calls != 1 || fx.source.last != want { + t.Fatalf("resolve = %d calls, last %+v; want one mapped request", fx.source.calls, fx.source.last) + } +} + +func testServiceCounts(t *testing.T) { + fx := newFixture(t) + root := repositoryTree(t, []string{"g1", "g2"}) + fx.source.identity = RepositoryIdentity{Root: root, Home: fx.source.identity.Home} + result, err := fx.service.Validate(context.Background(), Request{}) + if err != nil { + t.Fatalf("Validate: %v", err) + } + want := []PlatformCount{ + {Platform: "darwin", Files: 5, Secrets: 3, Groups: 2}, + {Platform: "linux", Files: 5, Secrets: 3, Groups: 2}, + } + if !slices.Equal(result.Platforms, want) { + t.Fatalf("platforms = %v, want %v", result.Platforms, want) + } + selected, err := fx.service.Validate(context.Background(), Request{Groups: []string{"g1"}}) + if err != nil { + t.Fatalf("Validate(g1): %v", err) + } + wantSelected := []PlatformCount{ + {Platform: "darwin", Files: 2, Secrets: 1, Groups: 1}, + {Platform: "linux", Files: 2, Secrets: 1, Groups: 1}, + } + if !slices.Equal(selected.Platforms, wantSelected) { + t.Fatalf("selected platforms = %v, want %v", selected.Platforms, wantSelected) + } + if fx.source.calls != 2 { + t.Fatalf("resolve calls = %d, want 2", fx.source.calls) + } +} + +func testServiceSourceFailure(t *testing.T) { + fx := newFixture(t) + fx.source.fail = errors.New("no default repository") + _, err := fx.service.Validate(context.Background(), Request{}) + assertInvalidInput(t, err) + if fx.source.calls != 1 { + t.Fatalf("resolve invoked %d times, want 1", fx.source.calls) + } +} + +func testServiceGroupFailures(t *testing.T) { + fx := newFixture(t) + root := repositoryTree(t, []string{"g1"}) + fx.source.identity = RepositoryIdentity{Root: root, Home: fx.source.identity.Home} + cases := []struct { + name string + groups []string + }{ + {"unknown", []string{"ghost"}}, + {"duplicate", []string{"g1", "g1"}}, + } + for _, scenario := range cases { + t.Run(scenario.name, func(t *testing.T) { + _, err := fx.service.Validate(context.Background(), Request{Groups: scenario.groups}) + assertInvalidInput(t, err) + }) + } +} + +func testServiceInvalidUnselectedScope(t *testing.T) { + fx := newFixture(t) + root := repositoryTree(t, []string{"g1", "g2"}) + fx.source.identity = RepositoryIdentity{Root: root, Home: fx.source.identity.Home} + writeFile(t, filepath.Join(root, "g2", "_routes.toml"), "not [valid toml") + _, err := fx.service.Validate(context.Background(), Request{Groups: []string{"g1"}}) + assertInvalidInput(t, err) +} + +func testServiceSecretShape(t *testing.T) { + cases := []struct { + name string + content string + want failure.Kind + }{ + {"valid json passes", `{"sops": {}}`, ""}, + {"malformed json fails", "{not json", failure.InvalidInput}, + {"empty storage fails", "", failure.InvalidInput}, + } + for _, scenario := range cases { + t.Run(scenario.name, func(t *testing.T) { + fx := newFixture(t) + root := repositoryTree(t, []string{"g1", "g2"}) + fx.source.identity = RepositoryIdentity{Root: root, Home: fx.source.identity.Home} + writeFile(t, filepath.Join(root, "g2", "_secrets", "g2-secret"), scenario.content) + _, err := fx.service.Validate(context.Background(), Request{Groups: []string{"g1"}}) + if scenario.want == "" { + if err != nil { + t.Fatalf("Validate: %v", err) + } + return + } + if kind, matched := failure.HasKind(err); !matched || kind != scenario.want { + t.Fatalf("error = %v, want %s", err, scenario.want) + } + }) + } + err := checkSecretShape(filepath.Join(t.TempDir(), "absent")) + if kind, matched := failure.HasKind(err); !matched || kind != failure.Operational { + t.Fatalf("missing secret error = %v, want Operational", err) + } +} diff --git a/internal/application/validate/types.go b/internal/application/validate/types.go new file mode 100644 index 0000000..7f767d2 --- /dev/null +++ b/internal/application/validate/types.go @@ -0,0 +1,79 @@ +// Package validate implements `cattery validate` (PLAN.md Section 11.2): it +// compiles and validates the full repository for Linux and Darwin, checks the +// JSON storage shape of every secret, and reports deterministic counts of the +// selected scopes. The package is Cobra-free: no CLI type appears here, and +// the CLI talks to the service through the frozen Request and Result shapes +// below. No target, SOPS, hook, prompt, or renderer is reachable. +package validate + +import ( + "github.com/alyraffauf/cattery/internal/deployment" + "github.com/alyraffauf/cattery/internal/repository" + "github.com/alyraffauf/cattery/internal/selection" +) + +// Dependencies bundles the injectable seams of the validation service. +// RepositorySource resolves the canonical repository pair for a raw request; +// Compiler compiles and validates platform plans; ProtectedTrees lists the +// trees compiled plans must never target, such as the state directory. +type Dependencies struct { + RepositorySource RepositorySource + Compiler Compiler + ProtectedTrees []string +} + +// RepositorySource resolves the canonical repository pair for a selection +// request. The composition root satisfies it with a selection resolver bound +// to the canonical home and the state default lookup. +type RepositorySource interface { + Resolve(selection.RepositoryRequest) (RepositoryIdentity, error) +} + +// RepositoryIdentity is the canonical repository pair one validation compiles +// from. It is the validate-owned projection of the lower selection result so +// no backend type leaks through the application seam. +type RepositoryIdentity struct { + Root string + Home string +} + +// Compiler validates and compiles one platform plan from a repository. +type Compiler interface { + Compile(repository.CompileInput) (deployment.Plan, error) +} + +// RepositoryInput carries the raw repository fields the CLI adapter copies +// mechanically: the explicit --repo value and its presence, the raw +// CATTERY_REPO value and its presence, and the initial working directory for +// relative resolution. Presence is significant: an empty value with presence +// blocks fallback. +type RepositoryInput struct { + RawExplicit string + ExplicitSet bool + RawEnv string + EnvSet bool + WorkingDir string +} + +// Request is the frozen input of one validation: the raw repository fields +// and the raw ordered group arguments. +type Request struct { + Repository RepositoryInput + Groups []string +} + +// PlatformCount summarizes the selected scopes of one compiled platform plan: +// total files, secret files, aliases, and group names. +type PlatformCount struct { + Platform string + Files int + Secrets int + Aliases int + Groups int +} + +// Result is the frozen outcome of one validation: the sorted Linux and +// Darwin platform counts. +type Result struct { + Platforms []PlatformCount +} diff --git a/internal/application/validate/types_test.go b/internal/application/validate/types_test.go new file mode 100644 index 0000000..fa536a9 --- /dev/null +++ b/internal/application/validate/types_test.go @@ -0,0 +1,161 @@ +package validate + +import ( + "context" + "go/parser" + "go/token" + "os" + "reflect" + "strings" + "testing" +) + +func TestValidateContract(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"zero request carries no repository or groups", testContractZeroRequest}, + {"repository input carries the five raw fields", testContractRepositoryInput}, + {"dependencies carry the narrow ports", testContractDependencyShape}, + {"result carries sorted platform counts", testContractResultShape}, + {"service exposes one validate method", testContractServiceSignature}, + {"no cli or third-party imports", testContractNoCLIImports}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +func testContractZeroRequest(t *testing.T) { + var request Request + if request.Repository != (RepositoryInput{}) { + t.Fatalf("zero Request.Repository = %+v, want the zero repository input", request.Repository) + } + if request.Groups != nil { + t.Fatalf("zero Request.Groups = %v, want nil", request.Groups) + } +} + +func testContractRepositoryInput(t *testing.T) { + repositoryInput := reflect.TypeOf(RepositoryInput{}) + want := map[string]reflect.Type{ + "RawExplicit": reflect.TypeOf(""), + "ExplicitSet": reflect.TypeOf(false), + "RawEnv": reflect.TypeOf(""), + "EnvSet": reflect.TypeOf(false), + "WorkingDir": reflect.TypeOf(""), + } + if repositoryInput.NumField() != len(want) { + t.Fatalf("RepositoryInput has %d fields, want %d", repositoryInput.NumField(), len(want)) + } + for name, fieldType := range want { + field, found := repositoryInput.FieldByName(name) + if !found || field.Type != fieldType { + t.Fatalf("RepositoryInput.%s type = %v, want %v", name, field.Type, fieldType) + } + } +} + +func testContractDependencyShape(t *testing.T) { + dependencies := reflect.TypeOf(Dependencies{}) + want := map[string]reflect.Type{ + "RepositorySource": reflect.TypeOf((*RepositorySource)(nil)).Elem(), + "Compiler": reflect.TypeOf((*Compiler)(nil)).Elem(), + "ProtectedTrees": reflect.TypeOf([]string(nil)), + } + if dependencies.NumField() != len(want) { + t.Fatalf("Dependencies has %d fields, want %d", dependencies.NumField(), len(want)) + } + for name, fieldType := range want { + field, found := dependencies.FieldByName(name) + if !found || field.Type != fieldType { + t.Fatalf("Dependencies.%s type = %v, want %v", name, field.Type, fieldType) + } + } +} + +func testContractResultShape(t *testing.T) { + field, found := reflect.TypeOf(Result{}).FieldByName("Platforms") + if !found || field.Type != reflect.TypeOf([]PlatformCount(nil)) { + t.Fatalf("Result.Platforms type = %v, want []PlatformCount", field.Type) + } + count := reflect.TypeOf(PlatformCount{}) + want := map[string]reflect.Type{ + "Platform": reflect.TypeOf(""), + "Files": reflect.TypeOf(0), + "Secrets": reflect.TypeOf(0), + "Aliases": reflect.TypeOf(0), + "Groups": reflect.TypeOf(0), + } + if count.NumField() != len(want) { + t.Fatalf("PlatformCount has %d fields, want %d", count.NumField(), len(want)) + } + for name, fieldType := range want { + field, found := count.FieldByName(name) + if !found || field.Type != fieldType { + t.Fatalf("PlatformCount.%s type = %v, want %v", name, field.Type, fieldType) + } + } +} + +func testContractServiceSignature(t *testing.T) { + method, found := reflect.TypeOf((*Service)(nil)).MethodByName("Validate") + if !found { + t.Fatal("Service.Validate method missing") + } + signature := method.Type + contextType := reflect.TypeOf((*context.Context)(nil)).Elem() + errorType := reflect.TypeOf((*error)(nil)).Elem() + if signature.NumIn() != 3 || + signature.In(1) != contextType || + signature.In(2) != reflect.TypeOf(Request{}) { + t.Fatalf("Validate parameters = %v, want (context.Context, Request)", signature) + } + if signature.NumOut() != 2 || + signature.Out(0) != reflect.TypeOf(Result{}) || + signature.Out(1) != errorType { + t.Fatalf("Validate results = %v, want (Result, error)", signature) + } +} + +func testContractNoCLIImports(t *testing.T) { + for _, name := range packageSources(t) { + assertCleanImports(t, name) + } +} + +func assertCleanImports(t *testing.T, name string) { + fileSet := token.NewFileSet() + file, err := parser.ParseFile(fileSet, name, nil, parser.ImportsOnly) + if err != nil { + t.Fatalf("parse %s: %v", name, err) + } + for _, spec := range file.Imports { + if isForbiddenImport(strings.Trim(spec.Path.Value, `"`)) { + t.Fatalf("%s imports %q", name, spec.Path.Value) + } + } +} + +func packageSources(t *testing.T) []string { + t.Helper() + entries, err := os.ReadDir(".") + if err != nil { + t.Fatal(err) + } + var sources []string + for _, entry := range entries { + name := entry.Name() + if strings.HasSuffix(name, ".go") && !strings.HasSuffix(name, "_test.go") { + sources = append(sources, name) + } + } + return sources +} + +func isForbiddenImport(path string) bool { + return strings.HasPrefix(path, "github.com/spf13/cobra") || + strings.HasPrefix(path, "github.com/spf13/pflag") || + strings.HasSuffix(path, "/internal/cli") +}