From 4abfadd0adc0e9e10f39d2fa26675be71bcd0024 Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Sun, 9 Aug 2026 21:36:20 -0400 Subject: [PATCH] feat: infer add ownership --- internal/application/add/infer.go | 267 +++++++++++++++++++++++++ internal/application/add/infer_test.go | 256 ++++++++++++++++++++++++ internal/application/add/types.go | 15 +- internal/application/add/types_test.go | 3 + 4 files changed, 539 insertions(+), 2 deletions(-) create mode 100644 internal/application/add/infer.go create mode 100644 internal/application/add/infer_test.go diff --git a/internal/application/add/infer.go b/internal/application/add/infer.go new file mode 100644 index 0000000..2e9929d --- /dev/null +++ b/internal/application/add/infer.go @@ -0,0 +1,267 @@ +package add + +import ( + "path/filepath" + "strings" + + "github.com/alyraffauf/cattery/internal/deployment" + "github.com/alyraffauf/cattery/internal/failure" + "github.com/alyraffauf/cattery/internal/pathsafe" + "github.com/alyraffauf/cattery/internal/repository" +) + +// inferContext bundles the read-only inputs of ownership inference so Infer +// stays under the parameter limit. Targets are canonical absolute paths +// resolved against the working directory before inference begins, so Infer +// never touches the filesystem. +type inferContext struct { + identity RepositoryIdentity + plan deployment.Plan + platform deployment.Layer + targets []string +} + +// targetRef pairs one canonical absolute target with its HOME-relative form. +type targetRef struct { + absolute string + relative string +} + +// sourceLocation is the inferred scope, layer, and kind of one source entry. +type sourceLocation struct { + scope deployment.Scope + layer deployment.Layer + kind deployment.FileKind +} + +// Infer derives one ItemPlanInput per target in raw command-line order. It is +// pure and read-only: each canonical absolute target is mapped to its owner +// when the plan already manages it, or to the inferred scope, layer, kind, and +// repository-relative source path under Section 2's grammar. ExecutableBits +// stays zero; preflight fills it from the live target mode. +func Infer(context inferContext, request Request) ([]ItemPlanInput, error) { + items := make([]ItemPlanInput, 0, len(context.targets)) + for _, target := range context.targets { + item, err := inferTarget(context, request, target) + if err != nil { + return nil, err + } + items = append(items, item) + } + return items, nil +} + +// inferTarget maps one target to its item plan, preferring an existing plan +// owner and rejecting alias and unrepresentable targets. +func inferTarget(context inferContext, request Request, target string) (ItemPlanInput, error) { + ref, err := resolveTargetRef(context.identity, target) + if err != nil { + return ItemPlanInput{}, err + } + if canonical, named := aliasCanonical(context.plan, ref.relative); named { + return ItemPlanInput{}, failure.New(failure.InvalidInput, + "add: target "+ref.relative+" is an alias; add "+canonical+" instead", nil) + } + if owner, managed := matchManagedFile(context.plan, ref.relative); managed { + return inferManaged(request, ref, owner) + } + return inferUnmanaged(context, request, ref) +} + +// inferManaged adopts the existing owner's scope, layer, kind, and source +// location, rejecting explicit options that contradict it. +func inferManaged(request Request, ref targetRef, owner deployment.ManagedFile) (ItemPlanInput, error) { + if err := contradictsOwner(owner, request); err != nil { + return ItemPlanInput{}, err + } + return ItemPlanInput{ + Scope: owner.Scope, + Layer: owner.Layer, + Kind: owner.Kind, + TargetAbsolutePath: ref.absolute, + TargetRelativePath: ref.relative, + SourceRepositoryPath: owner.SourceRepositoryPath, + SourceAbsolutePath: owner.SourceAbsolutePath, + }, nil +} + +// inferUnmanaged derives the default location, proves the target is +// representable there, and inverts Section 2's grammar to produce the source. +func inferUnmanaged(context inferContext, request Request, ref targetRef) (ItemPlanInput, error) { + location, err := inferLocation(context, request) + if err != nil { + return ItemPlanInput{}, err + } + if err := checkRepresentable(location.scope, location.layer, ref.relative); err != nil { + return ItemPlanInput{}, err + } + source := location.sourcePath(ref.relative) + if _, err := pathsafe.Segments(source); err != nil { + return ItemPlanInput{}, failure.New(failure.InvalidInput, "add: derived source path", err) + } + return ItemPlanInput{ + Scope: location.scope, + Layer: location.layer, + Kind: location.kind, + TargetAbsolutePath: ref.absolute, + TargetRelativePath: ref.relative, + SourceRepositoryPath: source, + SourceAbsolutePath: filepath.Join(context.identity.Root, source), + }, nil +} + +// inferLocation selects the scope, layer, and kind for an unmanaged target. +func inferLocation(context inferContext, request Request) (sourceLocation, error) { + layer, err := inferLayer(context, request) + if err != nil { + return sourceLocation{}, err + } + return sourceLocation{scope: inferScope(request), layer: layer, kind: inferKind(request)}, nil +} + +// sourcePath inverts Section 2's grammar into a repository-relative source +// path from the location and the HOME-relative target. +func (location sourceLocation) sourcePath(target string) string { + var builder strings.Builder + if !location.scope.IsRoot() { + builder.WriteString(location.scope.Group) + builder.WriteString("/") + } + if location.layer != deployment.LayerBase { + builder.WriteString("_") + builder.WriteString(string(location.layer)) + builder.WriteString("/") + } + if location.kind == deployment.FileSecret { + builder.WriteString("_secrets/") + } + builder.WriteString(target) + return builder.String() +} + +// resolveTargetRef strips the canonical home prefix and validates the result. +func resolveTargetRef(identity RepositoryIdentity, target string) (targetRef, error) { + relative, err := homeRelative(identity, target) + if err != nil { + return targetRef{}, err + } + return targetRef{absolute: target, relative: relative}, nil +} + +// inferScope selects an explicit group or the root default. +func inferScope(request Request) deployment.Scope { + if request.GroupSet { + return deployment.NewScope(request.Group) + } + return deployment.NewScope("") +} + +// inferLayer selects an explicit platform layer that must match the runtime +// platform, or the base default. +func inferLayer(context inferContext, request Request) (deployment.Layer, error) { + if !request.PlatformSet { + return deployment.LayerBase, nil + } + layer, err := deployment.ParseLayer(request.Platform) + if err != nil { + return "", failure.New(failure.InvalidInput, "add: --platform "+request.Platform, err) + } + if layer != context.platform { + return "", failure.New(failure.InvalidInput, + "add: --platform must equal runtime platform "+string(context.platform), nil) + } + return layer, nil +} + +// inferKind selects the secret kind only when --secret is present and true. +func inferKind(request Request) deployment.FileKind { + if request.SecretSet && request.Secret { + return deployment.FileSecret + } + return deployment.FileOrdinary +} + +// checkRepresentable enforces Section 2.1: underscore-prefixed targets are +// unrepresentable everywhere; root base additionally rejects metadata names +// and multi-segment non-dot targets that the grammar would route to a group. +func checkRepresentable(scope deployment.Scope, layer deployment.Layer, relative string) error { + first := strings.SplitN(relative, "/", 2)[0] + if strings.HasPrefix(first, "_") { + return failure.New(failure.InvalidInput, + "add: target "+relative+" begins with an underscore and is unrepresentable", nil) + } + if !scope.IsRoot() || layer != deployment.LayerBase { + return nil + } + if repository.ClassifyRoot(first) == repository.ControlMetadata { + return failure.New(failure.InvalidInput, + "add: target "+relative+" is a reserved metadata name; pass --group", nil) + } + if strings.Contains(relative, "/") && !strings.HasPrefix(first, ".") { + return failure.New(failure.InvalidInput, + "add: target "+relative+" is unrepresentable at the root base layer; pass --group", nil) + } + return nil +} + +// homeRelative strips the canonical home prefix from target and validates the +// remaining slash-relative form. +func homeRelative(identity RepositoryIdentity, target string) (string, error) { + prefix := identity.Home + "/" + if !strings.HasPrefix(target, prefix) { + return "", failure.New(failure.InvalidInput, "add: target is not beneath $HOME", nil) + } + relative := strings.TrimPrefix(target, prefix) + if _, err := pathsafe.Segments(relative); err != nil { + return "", failure.New(failure.InvalidInput, "add: target "+relative, err) + } + return relative, nil +} + +// matchManagedFile returns the plan owner whose target matches relative. +func matchManagedFile(plan deployment.Plan, relative string) (deployment.ManagedFile, bool) { + for _, file := range plan.Files() { + if file.TargetRelativePath == relative { + return file, true + } + } + return deployment.ManagedFile{}, false +} + +// aliasCanonical reports whether relative is a configured alias and returns +// the canonical target the caller should add instead. +func aliasCanonical(plan deployment.Plan, relative string) (string, bool) { + for _, alias := range plan.Aliases() { + if alias.AliasRelativePath == relative { + return alias.CanonicalTargetRelativePath, true + } + } + return "", false +} + +// contradictsOwner rejects explicit options that disagree with the owner. +func contradictsOwner(owner deployment.ManagedFile, request Request) error { + if request.GroupSet && request.Group != owner.Scope.Group { + return failure.New(failure.InvalidInput, + "add: --group conflicts with the existing owner of "+owner.TargetRelativePath, nil) + } + if request.PlatformSet && explicitLayer(request.Platform) != owner.Layer { + return failure.New(failure.InvalidInput, + "add: --platform conflicts with the existing owner of "+owner.TargetRelativePath, nil) + } + if request.SecretSet && request.Secret != (owner.Kind == deployment.FileSecret) { + return failure.New(failure.InvalidInput, + "add: --secret conflicts with the existing owner of "+owner.TargetRelativePath, nil) + } + return nil +} + +// explicitLayer parses the raw platform value, returning the zero layer on +// any error so a contradiction is reported rather than a parse failure. +func explicitLayer(value string) deployment.Layer { + layer, err := deployment.ParseLayer(value) + if err != nil { + return "" + } + return layer +} diff --git a/internal/application/add/infer_test.go b/internal/application/add/infer_test.go new file mode 100644 index 0000000..68c2d51 --- /dev/null +++ b/internal/application/add/infer_test.go @@ -0,0 +1,256 @@ +package add + +import ( + "testing" + + "github.com/alyraffauf/cattery/internal/deployment" +) + +func TestAddInference(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"unmanaged root base ordinary", testInferRootBaseOrdinary}, + {"unmanaged dot tree is representable", testInferDotTree}, + {"unmanaged root base secret", testInferRootBaseSecret}, + {"unmanaged group ordinary", testInferGroupOrdinary}, + {"unmanaged platform layer", testInferPlatformLayer}, + {"unmanaged group platform secret", testInferGroupPlatformSecret}, + {"managed adopts owner", testInferManagedAdopts}, + {"managed rejects conflicting group", testInferManagedConflictGroup}, + {"managed rejects conflicting platform", testInferManagedConflictPlatform}, + {"alias target rejected", testInferAliasRejected}, + {"underscore target rejected", testInferUnderscoreRejected}, + {"metadata name rejected", testInferMetadataRejected}, + {"multi-segment non-dot rejected", testInferMultiSegmentRejected}, + {"platform must equal runtime", testInferPlatformMismatch}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +func testInferRootBaseOrdinary(t *testing.T) { + item, err := inferOneCase(t, request{}, ".bashrc") + if err != nil { + t.Fatal(err) + } + assertItem(t, item, expectedItem{ + scope: deployment.NewScope(""), layer: deployment.LayerBase, + kind: deployment.FileOrdinary, source: ".bashrc", + }) +} + +func testInferDotTree(t *testing.T) { + item, err := inferOneCase(t, request{}, ".config/app/config.toml") + if err != nil { + t.Fatal(err) + } + assertItem(t, item, expectedItem{ + scope: deployment.NewScope(""), layer: deployment.LayerBase, + kind: deployment.FileOrdinary, source: ".config/app/config.toml", + }) +} + +func testInferRootBaseSecret(t *testing.T) { + item, err := inferOneCase(t, request{SecretSet: true, Secret: true}, ".aws/creds") + if err != nil { + t.Fatal(err) + } + assertItem(t, item, expectedItem{ + scope: deployment.NewScope(""), layer: deployment.LayerBase, + kind: deployment.FileSecret, source: "_secrets/.aws/creds", + }) +} + +func testInferGroupOrdinary(t *testing.T) { + item, err := inferOneCase(t, request{GroupSet: true, Group: "atuin"}, "config.toml") + if err != nil { + t.Fatal(err) + } + assertItem(t, item, expectedItem{ + scope: deployment.NewScope("atuin"), layer: deployment.LayerBase, + kind: deployment.FileOrdinary, source: "atuin/config.toml", + }) +} + +func testInferPlatformLayer(t *testing.T) { + item, err := inferOneCase(t, request{PlatformSet: true, Platform: "linux"}, "bin/tool") + if err != nil { + t.Fatal(err) + } + assertItem(t, item, expectedItem{ + scope: deployment.NewScope(""), layer: deployment.LayerLinux, + kind: deployment.FileOrdinary, source: "_linux/bin/tool", + }) +} + +func testInferGroupPlatformSecret(t *testing.T) { + item, err := inferOneCase(t, + request{GroupSet: true, Group: "atuin", PlatformSet: true, Platform: "linux", + SecretSet: true, Secret: true}, "db") + if err != nil { + t.Fatal(err) + } + assertItem(t, item, expectedItem{ + scope: deployment.NewScope("atuin"), layer: deployment.LayerLinux, + kind: deployment.FileSecret, source: "atuin/_linux/_secrets/db", + }) +} + +func testInferManagedAdopts(t *testing.T) { + plan := planWith(t, managedRoot(t, ".vimrc", deployment.FileOrdinary), nil) + context := inferContext{ + identity: RepositoryIdentity{Root: "/repo", Home: "/home/user"}, + plan: plan, platform: deployment.LayerLinux, + targets: []string{"/home/user/.vimrc"}, + } + items, err := Infer(context, request{}) + if err != nil { + t.Fatal(err) + } + if items[0].SourceRepositoryPath != ".vimrc" { + t.Fatalf("source = %q, want the owner path .vimrc", items[0].SourceRepositoryPath) + } +} + +func testInferManagedConflictGroup(t *testing.T) { + plan := planWith(t, managedRoot(t, ".vimrc", deployment.FileOrdinary), nil) + context := inferContext{ + identity: RepositoryIdentity{Root: "/repo", Home: "/home/user"}, + plan: plan, platform: deployment.LayerLinux, + targets: []string{"/home/user/.vimrc"}, + } + if _, err := Infer(context, request{GroupSet: true, Group: "other"}); err == nil { + t.Fatal("managed owner accepted a conflicting group") + } +} + +func testInferManagedConflictPlatform(t *testing.T) { + plan := planWith(t, managedRoot(t, ".vimrc", deployment.FileOrdinary), nil) + context := inferContext{ + identity: RepositoryIdentity{Root: "/repo", Home: "/home/user"}, + plan: plan, platform: deployment.LayerLinux, + targets: []string{"/home/user/.vimrc"}, + } + if _, err := Infer(context, request{PlatformSet: true, Platform: "linux"}); err == nil { + t.Fatal("managed base owner accepted a conflicting platform") + } +} + +func testInferAliasRejected(t *testing.T) { + alias, err := deployment.NewAlias(deployment.Alias{ + Scope: deployment.NewScope(""), Platform: "linux", + AliasRelativePath: "readme", CanonicalTargetRelativePath: "README.md", + }) + if err != nil { + t.Fatal(err) + } + plan := planWith(t, nil, []deployment.Alias{alias}) + context := inferContext{ + identity: RepositoryIdentity{Root: "/repo", Home: "/home/user"}, + plan: plan, platform: deployment.LayerLinux, + targets: []string{"/home/user/readme"}, + } + _, err = Infer(context, request{}) + if err == nil { + t.Fatal("alias target was accepted") + } +} + +func testInferUnderscoreRejected(t *testing.T) { + if _, err := inferOneCase(t, request{}, "_secrets/secret"); err == nil { + t.Fatal("underscore target was accepted") + } +} + +func testInferMetadataRejected(t *testing.T) { + if _, err := inferOneCase(t, request{}, ".gitignore"); err == nil { + t.Fatal("metadata name was accepted") + } +} + +func testInferMultiSegmentRejected(t *testing.T) { + if _, err := inferOneCase(t, request{}, "bin/tool"); err == nil { + t.Fatal("root base multi-segment non-dot target was accepted") + } +} + +func testInferPlatformMismatch(t *testing.T) { + context := inferContext{ + identity: RepositoryIdentity{Root: "/repo", Home: "/home/user"}, + plan: planWith(t, nil, nil), platform: deployment.LayerLinux, + targets: []string{"/home/user/bin/tool"}, + } + if _, err := Infer(context, request{PlatformSet: true, Platform: "darwin"}); err == nil { + t.Fatal("cross-platform add was accepted") + } +} + +// inferOneCase runs Infer for one unmanaged target with an empty plan. +func inferOneCase(t *testing.T, request request, relative string) (ItemPlanInput, error) { + t.Helper() + context := inferContext{ + identity: RepositoryIdentity{Root: "/repo", Home: "/home/user"}, + plan: planWith(t, nil, nil), platform: deployment.LayerLinux, + targets: []string{"/home/user/" + relative}, + } + items, err := Infer(context, request) + if err != nil { + return ItemPlanInput{}, err + } + return items[0], nil +} + +// request re-exports Request for table brevity; the field set mirrors the +// presence bits the CLI captures. +type request = Request + +// expectedItem bundles the inferred fields one assertion checks. +type expectedItem struct { + scope deployment.Scope + layer deployment.Layer + kind deployment.FileKind + source string +} + +func assertItem(t *testing.T, item ItemPlanInput, want expectedItem) { + t.Helper() + if item.Scope != want.scope { + t.Fatalf("scope = %v, want %v", item.Scope, want.scope) + } + if item.Layer != want.layer { + t.Fatalf("layer = %v, want %v", item.Layer, want.layer) + } + if item.Kind != want.kind { + t.Fatalf("kind = %v, want %v", item.Kind, want.kind) + } + if item.SourceRepositoryPath != want.source { + t.Fatalf("source = %q, want %q", item.SourceRepositoryPath, want.source) + } +} + +func planWith(t *testing.T, files []deployment.ManagedFile, aliases []deployment.Alias) deployment.Plan { + t.Helper() + plan, err := deployment.NewPlan(deployment.PlanInput{ + RepositoryRoot: "/repo", Platform: "linux", Files: files, Aliases: aliases, + }) + if err != nil { + t.Fatal(err) + } + return plan +} + +func managedRoot(t *testing.T, target string, kind deployment.FileKind) []deployment.ManagedFile { + t.Helper() + file, err := deployment.NewManagedFile(deployment.ManagedFile{ + Scope: deployment.NewScope(""), Layer: deployment.LayerBase, Kind: kind, + SourceAbsolutePath: "/repo/" + target, SourceRepositoryPath: target, + TargetRelativePath: target, + }) + if err != nil { + t.Fatal(err) + } + return []deployment.ManagedFile{file} +} diff --git a/internal/application/add/types.go b/internal/application/add/types.go index 162f959..21cf477 100644 --- a/internal/application/add/types.go +++ b/internal/application/add/types.go @@ -14,18 +14,29 @@ import ( "github.com/alyraffauf/cattery/internal/deployment" "github.com/alyraffauf/cattery/internal/filesystem" "github.com/alyraffauf/cattery/internal/repository" + "github.com/alyraffauf/cattery/internal/secrets" "github.com/alyraffauf/cattery/internal/selection" "github.com/alyraffauf/cattery/internal/state" ) // Dependencies bundles the injectable seams of the add service: repository -// resolution, plan compilation, atomic source replacement, and baseline -// persistence. Construction is side-effect-free; effects begin inside Add. +// resolution, plan compilation, atomic source replacement, baseline +// persistence, secret encryption, and the per-installation hash key. The +// concrete secrets client mirrors inspect; construction is side-effect-free +// and effects begin inside Add. type Dependencies struct { RepositorySource RepositorySource Compiler Compiler Writer AtomicWriter Baselines BaselineStore + Secrets *secrets.Client + HashKey Recoverer +} + +// Recoverer loads the per-installation secret hash key for keyed baselines. +// The state store satisfies it; add recovers the key once per batch. +type Recoverer interface { + RecoverHashKey() ([32]byte, error) } // RepositorySource resolves the canonical repository pair for a selection diff --git a/internal/application/add/types_test.go b/internal/application/add/types_test.go index b0d990c..b9dc914 100644 --- a/internal/application/add/types_test.go +++ b/internal/application/add/types_test.go @@ -12,6 +12,7 @@ import ( "github.com/alyraffauf/cattery/internal/deployment" "github.com/alyraffauf/cattery/internal/filesystem" "github.com/alyraffauf/cattery/internal/repository" + "github.com/alyraffauf/cattery/internal/secrets" "github.com/alyraffauf/cattery/internal/selection" "github.com/alyraffauf/cattery/internal/state" ) @@ -209,6 +210,8 @@ func assertDependencySeams(t *testing.T) { "Compiler": reflect.TypeOf((*Compiler)(nil)).Elem(), "Writer": reflect.TypeOf((*AtomicWriter)(nil)).Elem(), "Baselines": reflect.TypeOf((*BaselineStore)(nil)).Elem(), + "Secrets": reflect.TypeOf((*secrets.Client)(nil)), + "HashKey": reflect.TypeOf((*Recoverer)(nil)).Elem(), } if dependencies.NumField() != len(ports) { t.Fatalf("Dependencies has %d fields, want %d", dependencies.NumField(), len(ports)) -- 2.51.2