From 589e379ff594da8ab0ea99567e6969e70d2b2945 Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Mon, 10 Aug 2026 19:40:11 +0000 Subject: [PATCH] feat: add secrets lifecycle commands --- README.md | 7 ++++++- docs/secrets.md | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ integration/secrets_test.go | 55 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ internal/bootstrap/adapters.go | 8 ++++++++ internal/bootstrap/applications.go | 10 ++++++++++ internal/bootstrap/applications_test.go | 2 +- internal/bootstrap/build.go | 1 + internal/cli/root.go | 4 +++- internal/cli/root_test.go | 8 ++++++-- internal/cli/secrets.go | 121 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ internal/cli/secrets_test.go | 101 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ internal/filesystem/read.go | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ internal/filesystem/read_test.go | 31 +++++++++++++++++++++++++++++++ internal/secrets/client.go | 12 +++++++++++- internal/secrets/client_buffers_test.go | 9 +++++++++ internal/state/files.go | 23 +++++++++++++++++++++++ internal/state/source_refresh_test.go | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ internal/application/secretlifecycle/select.go | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ internal/application/secretlifecycle/service.go | 177 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ internal/application/secretlifecycle/service_test.go | 253 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ internal/application/secretlifecycle/types.go | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 21 file(s) changed, 1138 insertion(s)(+), 6 deletion(s)(-) diff --git a/README.md b/README.md --- a/README.md +++ b/README.md @@ -71,12 +71,17 @@ | `cattery apply [GROUP ...]` | Reconcile selected files with the repository. | | `cattery add [OPTIONS] TARGET ...` | Adopt existing files or directories into the repository. | | `cattery forget DIRECTORY --yes` | Stop managing a directory; leaves its files in `$HOME`. | +| `cattery secrets list [GROUP ...]` | List safe metadata for encrypted sources. | +| `cattery secrets verify [GROUP ...]` | Check that encrypted sources can be decrypted. | +| `cattery secrets reencrypt [GROUP ...]` | Preview or apply current SOPS rules and recipients. | | `cattery version` | Print build information. | Global options are `--repo PATH` and `--verbose`. `apply` supports `--dry-run`, `--non-interactive`, and `--no-hooks`; `add` supports `--group`, `--platform`, `--secret`, and `--dry-run`; `forget` supports `--dry-run` and requires `--yes` -to remove repository sources. +to remove repository sources. Secret lifecycle commands accept repeatable +`--source REPOSITORY_PATH` selectors; `secrets reencrypt` previews by default +and requires `--yes` to replace encrypted sources. ## Safety, in plain English diff --git a/docs/secrets.md b/docs/secrets.md --- a/docs/secrets.md +++ b/docs/secrets.md @@ -46,6 +46,54 @@ Use `--group` or `--platform` with `add` when you need to choose where the source belongs. Run `cattery status` and `cattery apply` as usual afterward. +`cattery add --secret` is the adoption workflow. The lifecycle commands below +inventory, verify, or rotate sources that are already managed; they do not +create, edit, or delete plaintext secrets. + +## Inventory and verification + +List every managed encrypted source across base, Linux, and macOS layers: + +```sh +cattery secrets list +cattery secrets verify +``` + +Both commands accept group arguments and repeatable exact source selectors. +Selectors are combined as a union: + +```sh +cattery secrets verify work --source _secrets/.config/example/root-token +``` + +`list` reads repository metadata only and does not invoke SOPS. `verify` +decrypts each selected source in memory, reports only `verified` or `failed`, +continues after independent failures, and never reads the corresponding file +under `$HOME`. + +## Rotate recipients or creation rules + +SOPS and `.sops.yaml` remain the authority for recipients. A practical +rotation is: + +1. Edit the repository's `.sops.yaml` creation rules or recipients. +2. Run `cattery secrets verify` to confirm the existing identities work. +3. Preview with `cattery secrets reencrypt` (or explicitly add `--dry-run`). +4. Apply with `cattery secrets reencrypt --yes`. +5. Review and commit the encrypted repository changes. + +Re-encryption decrypts the source in memory, encrypts the same bytes using the +repository-relative source path as SOPS's filename override, then decrypts the +new ciphertext and compares it with the original plaintext before publishing. +Publication preserves the source mode and uses Cattery's atomic replacement +path. Each source is processed independently, so a failure does not prevent +other selected sources from being checked or rotated. + +The command never synchronizes or inspects `$HOME`, runs hooks, or writes +plaintext state. A successful replacement refreshes only a matching active +baseline's raw encrypted-source fingerprint; inactive platform sources and +secrets without a baseline do not change state. + ## What Cattery protects - Encrypted source files stay encrypted in the repository. diff --git a/integration/secrets_test.go b/integration/secrets_test.go --- a/integration/secrets_test.go +++ b/integration/secrets_test.go @@ -23,9 +23,57 @@ {"dependency failure", testSecretsDependency}, {"hash key recovery", testSecretsKey}, {"real age round trip", testSecretsRealRoundTrip}, + {"secret lifecycle rotation", testSecretsLifecycleRotation}, } for _, scenario := range scenarios { t.Run(scenario.name, scenario.run) + } +} + +func testSecretsLifecycleRotation(t *testing.T) { + env := newExecEnv(t) + env.initRepository(t) + original := []byte(`{"data":"b2xk","sops":{"version":"3.9.0"}}`) + rotated := []byte(`{"data":"ZmFrZS1jaXBoZXI=","sops":{"version":"3.9.0"}}`) + basePath := filepath.Join(env.repo, "_secrets", "base-token") + platformPath := filepath.Join(env.repo, "_linux", "_secrets", "linux-token") + writeFile(t, basePath, original) + writeFile(t, platformPath, original) + writeFile(t, filepath.Join(env.home, "base-token"), []byte("unchanged base target")) + writeFile(t, filepath.Join(env.home, "linux-token"), []byte("unchanged platform target")) + env = fakeEnv(t, env) + + listed := env.secretRun(t, nil, "secrets", "list") + if listed.Code != 0 || !strings.Contains(listed.Stdout, "_secrets/base-token") || + !strings.Contains(listed.Stdout, "_linux/_secrets/linux-token") { + t.Fatalf("list: %+v", listed) + } + preview := env.secretRun(t, nil, "secrets", "reencrypt") + if preview.Code != 2 { + t.Fatalf("preview: %+v", preview) + } + requireFileBytes(t, basePath, original) + requireFileBytes(t, platformPath, original) + + applied := env.secretRun(t, nil, "secrets", "reencrypt", "--yes") + if applied.Code != 0 { + t.Fatalf("reencrypt: %+v", applied) + } + requireFileBytes(t, basePath, rotated) + requireFileBytes(t, platformPath, rotated) + verified := env.secretRun(t, nil, "secrets", "verify") + if verified.Code != 0 || strings.Count(verified.Stdout, " verified ") != 2 { + t.Fatalf("verify: %+v", verified) + } + requireFileBytes(t, filepath.Join(env.home, "base-token"), []byte("unchanged base target")) + requireFileBytes(t, filepath.Join(env.home, "linux-token"), []byte("unchanged platform target")) +} + +func requireFileBytes(t *testing.T, path string, want []byte) { + t.Helper() + got, err := os.ReadFile(path) + if err != nil || !bytes.Equal(got, want) { + t.Fatalf("file %s = %q, %v; want %q", path, got, err, want) } } @@ -162,6 +210,13 @@ result := env.secretRun(t, nil, "apply") if result.Code != 4 { t.Fatalf("code = %d, want 4 for a missing sops dependency", result.Code) + } + verified := env.secretRun(t, nil, "secrets", "verify") + if verified.Code != 4 || !strings.Contains(verified.Stdout, "failed") { + t.Fatalf("verify = %+v, want a rendered dependency failure", verified) + } + if string(readTargetFile(t, env.home, "token")) != "plaintext" { + t.Fatal("verification changed the target") } } diff --git a/internal/bootstrap/adapters.go b/internal/bootstrap/adapters.go --- a/internal/bootstrap/adapters.go +++ b/internal/bootstrap/adapters.go @@ -86,6 +86,14 @@ store *state.Store } +// RefreshSecretSourceHash updates only a matching active secret baseline. +func (adapter baselineAdapter) RefreshSecretSourceHash(root, home, source string, hash deployment.Digest) (bool, error) { + if err := adapter.store.EnsureAcquired(); err != nil { + return false, err + } + return adapter.store.RefreshSecretSourceHash(root, home, source, hash) +} + // UpsertFileBaseline establishes or replaces one file row. func (adapter baselineAdapter) UpsertFileBaseline(root, home string, baseline state.FileBaseline) (state.FileBaseline, error) { if err := adapter.store.EnsureAcquired(); err != nil { diff --git a/internal/bootstrap/applications.go b/internal/bootstrap/applications.go --- a/internal/bootstrap/applications.go +++ b/internal/bootstrap/applications.go @@ -9,6 +9,7 @@ "github.com/alyraffauf/cattery/internal/application/initialize" "github.com/alyraffauf/cattery/internal/application/inspect" applicationrepository "github.com/alyraffauf/cattery/internal/application/repository" + "github.com/alyraffauf/cattery/internal/application/secretlifecycle" "github.com/alyraffauf/cattery/internal/application/validate" "github.com/alyraffauf/cattery/internal/cli" "github.com/alyraffauf/cattery/internal/deployment" @@ -24,6 +25,7 @@ Apply *apply.Service Add *add.Service Forget *forget.Service + Secrets *secretlifecycle.Service } // ApplicationsInput carries the adapters, home, platform, protected @@ -84,7 +86,15 @@ Apply: buildApply(input, shared), Add: buildAdd(input, shared), Forget: buildForget(input, shared), + Secrets: buildSecrets(input, shared), } +} + +func buildSecrets(input ApplicationsInput, shared shared) *secretlifecycle.Service { + return secretlifecycle.NewService(secretlifecycle.Dependencies{ + RepositorySource: repositorySourceOf(shared.resolver, repositoryIdentity), + Secrets: input.Adapters.SOPS, Writer: input.Adapters.Replacer, Baselines: shared.baselines, + }) } // buildForget wires source removal and baseline retirement without granting diff --git a/internal/bootstrap/applications_test.go b/internal/bootstrap/applications_test.go --- a/internal/bootstrap/applications_test.go +++ b/internal/bootstrap/applications_test.go @@ -43,7 +43,7 @@ func testApplicationsBuilt(t *testing.T) { applications, _ := appFixture(t) if applications.Initialize == nil || applications.Validate == nil || applications.Inspect == nil || - applications.Apply == nil || applications.Add == nil { + applications.Apply == nil || applications.Add == nil || applications.Secrets == nil { t.Fatal("every application service must be constructed") } } diff --git a/internal/bootstrap/build.go b/internal/bootstrap/build.go --- a/internal/bootstrap/build.go +++ b/internal/bootstrap/build.go @@ -56,6 +56,7 @@ Diff: services.Inspect, Add: services.Add, Forget: services.Forget, + Secrets: services.Secrets, Apply: services.Apply, }, runtimeValues) } diff --git a/internal/cli/root.go b/internal/cli/root.go --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -16,6 +16,7 @@ Diff DiffService Add AddService Forget ForgetService + Secrets SecretsService Apply ApplyService } @@ -27,7 +28,7 @@ } // NewApplication builds one opaque application over the dependencies and -// runtime: seven operational commands, persistent repository and verbose +// runtime: operational commands, persistent repository and verbose // flags, injected streams, no root Version, no completion or suggestions, // and no OnInitialize hook. Construction touches no backend. func NewApplication(dependencies Dependencies, runtime Runtime) *Application { @@ -57,6 +58,7 @@ newDiffCommand(dependencies.Diff, runtime, options), newAddCommand(dependencies.Add, runtime, options), newForgetCommand(dependencies.Forget, runtime, options), + newSecretsCommand(dependencies.Secrets, runtime, options), newApplyCommand(dependencies.Apply, runtime, options), ) return &Application{root: root} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -31,6 +31,7 @@ diff *diffServiceFake add *addServiceFake apply *applyServiceFake + secrets *secretsServiceFake } // rootFixture builds one application over recording services. @@ -43,6 +44,7 @@ diff: &diffServiceFake{}, add: &addServiceFake{}, apply: &applyServiceFake{}, + secrets: &secretsServiceFake{}, } stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} @@ -56,6 +58,7 @@ Diff: fakes.diff, Add: fakes.add, Apply: fakes.apply, + Secrets: fakes.secrets, }, runtime) return application, fakes, stdout, stderr } @@ -64,7 +67,8 @@ func zeroCalls(fakes *rootFakes) bool { return len(fakes.initialize.requests) == 0 && len(fakes.validate.requests) == 0 && len(fakes.status.requests) == 0 && len(fakes.diff.requests) == 0 && - len(fakes.add.requests) == 0 && len(fakes.apply.requests) == 0 + len(fakes.add.requests) == 0 && len(fakes.apply.requests) == 0 && + len(fakes.secrets.listRequests) == 0 && len(fakes.secrets.verifyRequests) == 0 && len(fakes.secrets.reencryptRequests) == 0 } func testRootHelp(t *testing.T) { @@ -85,7 +89,7 @@ if err := application.Execute(context.Background(), []string{"--help"}); err != nil { t.Fatalf("run: %v", err) } - for _, name := range []string{"init", "validate", "version", "status", "diff", "add", "apply"} { + for _, name := range []string{"init", "validate", "version", "status", "diff", "add", "apply", "secrets"} { if !strings.Contains(stdout.String(), name) { t.Fatalf("stdout = %q, want the %s command listed", stdout.String(), name) } diff --git a/internal/cli/secrets.go b/internal/cli/secrets.go new file mode 100644 --- /dev/null +++ b/internal/cli/secrets.go @@ -0,0 +1,121 @@ +package cli + +import ( + "context" + "fmt" + "io" + + "github.com/alyraffauf/cattery/internal/application/secretlifecycle" + "github.com/spf13/cobra" +) + +// SecretsService is the lifecycle role used by the secrets command family. +type SecretsService interface { + List(context.Context, secretlifecycle.Request) (secretlifecycle.Result, error) + Verify(context.Context, secretlifecycle.Request) (secretlifecycle.Result, error) + Reencrypt(context.Context, secretlifecycle.Request) (secretlifecycle.Result, error) +} + +func newSecretsCommand(service SecretsService, runtime Runtime, options *Options) *cobra.Command { + command := &cobra.Command{Use: "secrets", Short: "Inspect and rotate encrypted sources"} + command.AddCommand( + newSecretsListCommand(service, runtime, options), + newSecretsVerifyCommand(service, runtime, options), + newSecretsReencryptCommand(service, runtime, options), + ) + return command +} + +func newSecretsListCommand(service SecretsService, runtime Runtime, options *Options) *cobra.Command { + return newSecretsLeaf(secretsLeafSpec{ + use: "list [GROUP ...]", short: "List managed encrypted sources", runtime: runtime, options: options, inventory: true, + call: func(ctx context.Context, request secretlifecycle.Request) (secretlifecycle.Result, error) { + return service.List(ctx, request) + }, + }) +} + +func newSecretsVerifyCommand(service SecretsService, runtime Runtime, options *Options) *cobra.Command { + return newSecretsLeaf(secretsLeafSpec{ + use: "verify [GROUP ...]", short: "Verify managed encrypted sources", runtime: runtime, options: options, + call: func(ctx context.Context, request secretlifecycle.Request) (secretlifecycle.Result, error) { + return service.Verify(ctx, request) + }, + }) +} + +func newSecretsReencryptCommand(service SecretsService, runtime Runtime, options *Options) *cobra.Command { + return newSecretsLeaf(secretsLeafSpec{ + use: "reencrypt [GROUP ...]", short: "Re-encrypt sources with current SOPS rules", runtime: runtime, options: options, mutation: true, + call: func(ctx context.Context, request secretlifecycle.Request) (secretlifecycle.Result, error) { + return service.Reencrypt(ctx, request) + }, + }) +} + +type secretsCall func(context.Context, secretlifecycle.Request) (secretlifecycle.Result, error) + +type secretsLeafSpec struct { + use string + short string + runtime Runtime + options *Options + call secretsCall + mutation bool + inventory bool +} + +func newSecretsLeaf(spec secretsLeafSpec) *cobra.Command { + command := &cobra.Command{ + Use: spec.use, Short: spec.short, Args: cobra.ArbitraryArgs, + RunE: func(command *cobra.Command, groups []string) error { + sources, _ := command.Flags().GetStringArray("source") + dryRun, _ := command.Flags().GetBool("dry-run") + yes, _ := command.Flags().GetBool("yes") + result, err := spec.call(command.Context(), secretlifecycle.Request{ + Repository: secretsRepository(*spec.options, command, spec.runtime), + Groups: append([]string(nil), groups...), Sources: sources, DryRun: dryRun, Yes: yes, + }) + if renderErr := renderSecrets(spec.runtime.Stdout(), result, spec.inventory); renderErr != nil { + return renderErr + } + return err + }, + } + command.Flags().StringArray("source", nil, "select an exact repository-relative encrypted source") + if spec.mutation { + command.Flags().Bool("dry-run", false, "preview without replacing encrypted sources") + command.Flags().Bool("yes", false, "replace encrypted sources") + } + return command +} + +func secretsRepository(options Options, command *cobra.Command, runtime Runtime) secretlifecycle.RepositoryInput { + options.RepositorySet = options.RepositorySet || command.Flags().Changed("repo") + environment, environmentSet := runtime.EnvValue("CATTERY_REPO") + return secretlifecycle.RepositoryInput{ + RawExplicit: options.Repository, ExplicitSet: options.RepositorySet, + RawEnv: environment, EnvSet: environmentSet, WorkingDir: runtime.WorkingDir(), + } +} + +func renderSecrets(writer io.Writer, result secretlifecycle.Result, inventory bool) error { + for _, item := range result.Items { + group := item.Group + if group == "" { + group = "root" + } + if inventory { + if _, err := fmt.Fprintf(writer, "%s -> $HOME/%s group=%s layer=%s kind=%s\n", + displayPath(item.Source), displayPath(item.Target), displayPath(group), item.Layer, item.Kind); err != nil { + return err + } + continue + } + if _, err := fmt.Fprintf(writer, "%s %s $HOME/%s group=%s layer=%s kind=%s\n", + displayPath(item.Source), item.Status, displayPath(item.Target), displayPath(group), item.Layer, item.Kind); err != nil { + return err + } + } + return nil +} diff --git a/internal/cli/secrets_test.go b/internal/cli/secrets_test.go new file mode 100644 --- /dev/null +++ b/internal/cli/secrets_test.go @@ -0,0 +1,101 @@ +package cli + +import ( + "bytes" + "context" + "testing" + + "github.com/alyraffauf/cattery/internal/application/secretlifecycle" + "github.com/alyraffauf/cattery/internal/deployment" + "github.com/alyraffauf/cattery/internal/failure" +) + +func TestSecretsCommandMapsUnionSelectorsAndFlags(t *testing.T) { + service := &secretsServiceFake{reencryptResult: lifecycleResult("planned")} + stdout := &bytes.Buffer{} + runtime := NewRuntime(RuntimeInput{ + Streams: Streams{Stdout: stdout}, WorkingDir: "/work", + Environment: []string{"CATTERY_REPO=environment"}, + }) + options := Options{} + command := newSecretsCommand(service, runtime, &options) + bindSharedFlags(command, &options) + command.SetArgs([]string{"reencrypt", "apps", "--source", "_secrets/root", "--source", "apps/_linux/_secrets/token", "--dry-run"}) + if err := command.Execute(); err != nil { + t.Fatal(err) + } + request := service.reencryptRequests[0] + if len(request.Groups) != 1 || request.Groups[0] != "apps" || len(request.Sources) != 2 || !request.DryRun || request.Yes { + t.Fatalf("request = %+v", request) + } + if request.Repository.RawEnv != "environment" || !request.Repository.EnvSet { + t.Fatalf("repository = %+v", request.Repository) + } +} + +func TestSecretsRenderingIsSafeAndPreservesDifference(t *testing.T) { + service := &secretsServiceFake{ + reencryptResult: lifecycleResult("planned"), + reencryptError: failure.New(failure.Difference, "secrets reencrypt: changes pending", nil), + } + stdout := &bytes.Buffer{} + runtime := NewRuntime(RuntimeInput{Streams: Streams{Stdout: stdout}}) + command := newSecretsCommand(service, runtime, &Options{}) + command.SetArgs([]string{"reencrypt"}) + err := command.Execute() + if !kindIs(err, failure.Difference) { + t.Fatalf("error = %v", err) + } + want := "apps/_secrets/token planned $HOME/.config/app/token group=apps layer=linux kind=secret\n" + if stdout.String() != want { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +func TestSecretsListOmitsStatusAndPlaintext(t *testing.T) { + service := &secretsServiceFake{listResult: lifecycleResult("")} + stdout := &bytes.Buffer{} + command := newSecretsCommand(service, NewRuntime(RuntimeInput{Streams: Streams{Stdout: stdout}}), &Options{}) + command.SetArgs([]string{"list"}) + if err := command.Execute(); err != nil { + t.Fatal(err) + } + want := "apps/_secrets/token -> $HOME/.config/app/token group=apps layer=linux kind=secret\n" + if stdout.String() != want { + t.Fatalf("stdout = %q", stdout.String()) + } +} + +type secretsServiceFake struct { + listRequests []secretlifecycle.Request + verifyRequests []secretlifecycle.Request + reencryptRequests []secretlifecycle.Request + listResult secretlifecycle.Result + verifyResult secretlifecycle.Result + reencryptResult secretlifecycle.Result + listError error + verifyError error + reencryptError error +} + +func (service *secretsServiceFake) List(_ context.Context, request secretlifecycle.Request) (secretlifecycle.Result, error) { + service.listRequests = append(service.listRequests, request) + return service.listResult, service.listError +} + +func (service *secretsServiceFake) Verify(_ context.Context, request secretlifecycle.Request) (secretlifecycle.Result, error) { + service.verifyRequests = append(service.verifyRequests, request) + return service.verifyResult, service.verifyError +} + +func (service *secretsServiceFake) Reencrypt(_ context.Context, request secretlifecycle.Request) (secretlifecycle.Result, error) { + service.reencryptRequests = append(service.reencryptRequests, request) + return service.reencryptResult, service.reencryptError +} + +func lifecycleResult(status string) secretlifecycle.Result { + return secretlifecycle.Result{Items: []secretlifecycle.Item{{ + Source: "apps/_secrets/token", Target: ".config/app/token", Group: "apps", + Layer: deployment.LayerLinux, Kind: deployment.FileSecret, Status: status, + }}} +} diff --git a/internal/filesystem/read.go b/internal/filesystem/read.go new file mode 100644 --- /dev/null +++ b/internal/filesystem/read.go @@ -0,0 +1,52 @@ +package filesystem + +import ( + "fmt" + "io" + "os" +) + +// ReadFrozen reads the regular file named by precondition through one open +// descriptor and proves that descriptor and the path stayed equal to the +// frozen entry before and after the read. +func ReadFrozen(precondition Precondition) ([]byte, error) { + frozen := precondition.Target() + if frozen.Kind() != KindFile { + return nil, fmt.Errorf("filesystem: frozen source is not a regular file") + } + if err := precondition.Revalidate(); err != nil { + return nil, err + } + + file, err := os.Open(targetPath(precondition.Destination())) + if err != nil { + return nil, err + } + defer file.Close() + if err := validateOpenedFile(file, frozen); err != nil { + return nil, err + } + + content, err := io.ReadAll(file) + if err != nil { + return nil, err + } + if err := validateOpenedFile(file, frozen); err != nil { + return nil, err + } + if err := precondition.Revalidate(); err != nil { + return nil, err + } + return content, nil +} + +func validateOpenedFile(file *os.File, frozen TargetFacts) error { + info, err := file.Stat() + if err != nil { + return err + } + if !info.Mode().IsRegular() || !MatchesIdentityAndMode(frozen.Identity(), info, frozen.Mode()) { + return fmt.Errorf("filesystem: opened source changed at %s", frozen.Identity().Path()) + } + return nil +} diff --git a/internal/filesystem/read_test.go b/internal/filesystem/read_test.go new file mode 100644 --- /dev/null +++ b/internal/filesystem/read_test.go @@ -0,0 +1,31 @@ +package filesystem + +import ( + "os" + "path/filepath" + "testing" +) + +func TestReadFrozenReadsExactRegularFile(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "source") + must(t, os.WriteFile(path, []byte("ciphertext"), 0o640)) + frozen := mustFreeze(t, root, "source") + content, err := ReadFrozen(frozen) + if err != nil || string(content) != "ciphertext" { + t.Fatalf("content = %q, error = %v", content, err) + } +} + +func TestReadFrozenRejectsReplacement(t *testing.T) { + root := t.TempDir() + path := filepath.Join(root, "source") + must(t, os.WriteFile(path, []byte("first"), 0o600)) + frozen := mustFreeze(t, root, "source") + replacement := filepath.Join(root, "replacement") + must(t, os.WriteFile(replacement, []byte("second"), 0o600)) + must(t, os.Rename(replacement, path)) + if _, err := ReadFrozen(frozen); err == nil { + t.Fatal("replacement was accepted") + } +} diff --git a/internal/secrets/client.go b/internal/secrets/client.go --- a/internal/secrets/client.go +++ b/internal/secrets/client.go @@ -11,6 +11,7 @@ "errors" "fmt" "slices" + "strconv" "github.com/alyraffauf/cattery/internal/failure" "github.com/alyraffauf/cattery/internal/subprocess" @@ -69,7 +70,7 @@ defer cancel() stdout := newBounded(request.StdoutLimit, cancel) stderr := newDrain(maxStderr) - diag := diagnosis{operation: request.Operation, source: request.SourcePath} + diag := diagnosis{operation: request.Operation, source: safeDiagnosticPath(request.SourcePath)} result, runError := subprocess.Run(ctx, subprocess.Request{ Command: executableArgs(client.executable, request.Arguments), Directory: client.directory, @@ -84,6 +85,15 @@ return nil, err } return stdout.buf, nil +} + +func safeDiagnosticPath(path string) string { + for _, character := range path { + if character < 0x20 || character == 0x7f || character == ' ' { + return strconv.Quote(path) + } + } + return path } // diagnosis carries the two context strings that may appear in sanitized diff --git a/internal/secrets/client_buffers_test.go b/internal/secrets/client_buffers_test.go --- a/internal/secrets/client_buffers_test.go +++ b/internal/secrets/client_buffers_test.go @@ -11,6 +11,15 @@ "github.com/alyraffauf/cattery/internal/testfixture/sops" ) +func TestSafeDiagnosticPathQuotesLineBreakingNames(t *testing.T) { + if got := safeDiagnosticPath("app/token"); got != "app/token" { + t.Fatalf("ordinary path = %q", got) + } + if got := safeDiagnosticPath("app/line\nbreak"); got != `"app/line\nbreak"` { + t.Fatalf("unsafe path = %q", got) + } +} + func testLargeStderr(t *testing.T) { executable := sops.Build(t) repository := t.TempDir() diff --git a/internal/state/files.go b/internal/state/files.go --- a/internal/state/files.go +++ b/internal/state/files.go @@ -27,6 +27,11 @@ const fileRetireSQL = "UPDATE files SET status = 'retired', retired_at = ? WHERE repository_id = ? AND target_path = ?" +const refreshSecretSourceHashSQL = ` +UPDATE files SET baseline_source_hash = ? +WHERE repository_id = (SELECT id FROM repositories WHERE root_path = ? AND home_path = ?) + AND source_path = ? AND source_kind = 'secret' AND status = 'active'` + const fileByPairTargetSQL = ` SELECT ` + fileColumns + ` FROM files f JOIN repositories r ON r.id = f.repository_id @@ -144,4 +149,22 @@ return nil, err } return store.readFileGroups(allFileGroupsSQL, root, home) +} + +// RefreshSecretSourceHash updates the raw ciphertext fingerprint only when an +// active secret baseline for the canonical repository pair names source. +func (store *Store) RefreshSecretSourceHash(root, home, source string, hash deployment.Digest) (bool, error) { + root, home, err := canonicalRepositoryPair(root, home) + if err != nil { + return false, err + } + if !IsSlashRelative(source) { + return false, fmt.Errorf("state: file source %q is not a slash-relative path", source) + } + result, err := store.database.conn.Exec(refreshSecretSourceHashSQL, hash[:], root, home, source) + if err != nil { + return false, err + } + changed, err := result.RowsAffected() + return changed > 0, err } diff --git a/internal/state/source_refresh_test.go b/internal/state/source_refresh_test.go new file mode 100644 --- /dev/null +++ b/internal/state/source_refresh_test.go @@ -0,0 +1,50 @@ +package state + +import ( + "testing" + + "github.com/alyraffauf/cattery/internal/deployment" +) + +func TestRefreshSecretSourceHashOnlyTouchesMatchingActiveSecret(t *testing.T) { + store := openStore(t, tempDependencies(t)) + root, home := t.TempDir(), t.TempDir() + active := secretBaseline("active", "", 1) + active.SourcePath = "_secrets/active" + dormant := secretBaseline("dormant", "", 2) + dormant.SourcePath = "_darwin/_secrets/dormant" + ordinary := ordinaryBaseline("ordinary", "", 3) + ordinary.SourcePath = "ordinary" + for _, baseline := range []FileBaseline{active, dormant, ordinary} { + if _, err := store.UpsertFileBaseline(root, home, baseline); err != nil { + t.Fatal(err) + } + } + if _, err := store.RetireFileBaseline(root, home, "dormant"); err != nil { + t.Fatal(err) + } + + want := deployment.RawStorage([]byte("rotated")) + changed, err := store.RefreshSecretSourceHash(root, home, "_secrets/active", want) + if err != nil || !changed { + t.Fatalf("refresh = %v, %v", changed, err) + } + for _, source := range []string{"_darwin/_secrets/dormant", "ordinary", "_secrets/missing"} { + changed, err := store.RefreshSecretSourceHash(root, home, source, want) + if err != nil || changed { + t.Fatalf("refresh %s = %v, %v", source, changed, err) + } + } + row, err := store.FileBaseline(root, home, "active") + if err != nil || row.BaselineSourceHash != want { + t.Fatalf("active row = %+v, %v", row, err) + } +} + +func TestRefreshSecretSourceHashIgnoresAbsentRepositoryBaseline(t *testing.T) { + store := openStore(t, tempDependencies(t)) + changed, err := store.RefreshSecretSourceHash(t.TempDir(), t.TempDir(), "_secrets/missing", sampleDigest(9)) + if err != nil || changed { + t.Fatalf("refresh = %v, %v", changed, err) + } +} diff --git a/internal/application/secretlifecycle/select.go b/internal/application/secretlifecycle/select.go new file mode 100644 --- /dev/null +++ b/internal/application/secretlifecycle/select.go @@ -0,0 +1,99 @@ +package secretlifecycle + +import ( + "fmt" + "slices" + "strings" + + applicationrepository "github.com/alyraffauf/cattery/internal/application/repository" + "github.com/alyraffauf/cattery/internal/deployment" + "github.com/alyraffauf/cattery/internal/failure" + "github.com/alyraffauf/cattery/internal/pathsafe" + "github.com/alyraffauf/cattery/internal/repository" +) + +type selectedSource struct { + source repository.Source +} + +func (service *Service) resolveAndSelect(request Request) (applicationrepository.RepositoryIdentity, []selectedSource, error) { + identity, err := service.deps.RepositorySource.Resolve(repositoryRequest(request.Repository)) + if err != nil { + return identity, nil, failure.New(failure.InvalidInput, "secrets: resolve repository", err) + } + sources, err := repository.Sources(identity.Root) + if err != nil { + return identity, nil, failure.FromPathError("secrets: scan repository", err) + } + scan, err := repository.Scan(identity.Root) + if err != nil { + return identity, nil, failure.FromPathError("secrets: scan repository groups", err) + } + selected, err := selectSources(sources, scan.Groups, request.Groups, request.Sources) + if err != nil { + return identity, nil, failure.New(failure.InvalidInput, "secrets: select sources", err) + } + return identity, selected, nil +} + +func selectSources(sources []repository.Source, groupsFound, groups, paths []string) ([]selectedSource, error) { + for _, group := range groups { + if !slices.Contains(groupsFound, group) { + return nil, fmt.Errorf("unknown group %q", group) + } + } + requestedPaths, err := validateSourcePaths(paths) + if err != nil { + return nil, err + } + + selected := make([]selectedSource, 0) + seenPaths := make(map[string]bool) + for _, source := range sources { + path := source.Candidate.SourceRepoPath + matches := len(groups) == 0 && len(paths) == 0 + matches = matches || slices.Contains(groups, source.Candidate.Scope.Group) || requestedPaths[path] + if !matches { + continue + } + if source.Candidate.Kind != deployment.FileSecret { + if requestedPaths[path] { + return nil, fmt.Errorf("source %q is not a secret", path) + } + continue + } + selected = append(selected, selectedSource{source: source}) + seenPaths[path] = true + } + for path := range requestedPaths { + if !seenPaths[path] { + return nil, fmt.Errorf("unknown source %q", path) + } + } + return selected, nil +} + +func validateSourcePaths(paths []string) (map[string]bool, error) { + validated := make(map[string]bool, len(paths)) + for _, path := range paths { + if _, err := pathsafe.Segments(path); err != nil { + return nil, fmt.Errorf("invalid source %q: %w", path, err) + } + if strings.ContainsRune(path, '\\') { + return nil, fmt.Errorf("invalid source %q: backslash separator", path) + } + if validated[path] { + return nil, fmt.Errorf("duplicate source %q", path) + } + validated[path] = true + } + return validated, nil +} + +func itemOf(selected selectedSource, status string) Item { + candidate := selected.source.Candidate + return Item{ + Source: candidate.SourceRepoPath, Target: selected.source.TargetRelativePath, + Group: candidate.Scope.Group, Layer: candidate.Layer, Kind: candidate.Kind, Status: status, + } +} diff --git a/internal/application/secretlifecycle/service.go b/internal/application/secretlifecycle/service.go new file mode 100644 --- /dev/null +++ b/internal/application/secretlifecycle/service.go @@ -0,0 +1,177 @@ +package secretlifecycle + +import ( + "bytes" + "context" + "errors" + "fmt" + "io/fs" + + applicationrepository "github.com/alyraffauf/cattery/internal/application/repository" + "github.com/alyraffauf/cattery/internal/deployment" + "github.com/alyraffauf/cattery/internal/failure" + "github.com/alyraffauf/cattery/internal/filesystem" +) + +// List returns repository metadata without invoking SOPS or reading HOME. +func (service *Service) List(_ context.Context, request Request) (Result, error) { + _, selected, err := service.resolveAndSelect(request) + if err != nil { + return Result{}, err + } + return resultWithStatus(selected, ""), nil +} + +// Verify decrypts each selected source independently and exposes no content. +func (service *Service) Verify(ctx context.Context, request Request) (Result, error) { + identity, selected, err := service.resolveAndSelect(request) + if err != nil { + return Result{}, err + } + service.deps.Secrets.SetDirectory(identity.Root) + result := Result{Items: make([]Item, 0, len(selected))} + var failures []error + for _, source := range selected { + status := "verified" + if err := service.verifyOne(ctx, identity, source); err != nil { + status = "failed" + failures = append(failures, err) + } + result.Items = append(result.Items, itemOf(source, status)) + } + return result, errors.Join(failures...) +} + +func (service *Service) verifyOne(ctx context.Context, identity applicationrepository.RepositoryIdentity, selected selectedSource) error { + content, _, err := readSource(identity.Root, selected) + if err != nil { + return itemFailure("verify", selected, err) + } + defer clearBytes(content) + plaintext, err := service.deps.Secrets.Decrypt(ctx, content, selected.source.Candidate.SourceRepoPath) + if err != nil { + return itemFailure("verify", selected, err) + } + clearBytes(plaintext) + return nil +} + +// Reencrypt validates fresh ciphertext for every item and publishes it only +// when Yes is set. Preview is the default and reports a Difference result. +func (service *Service) Reencrypt(ctx context.Context, request Request) (Result, error) { + if request.Yes && request.DryRun { + return Result{}, failure.New(failure.InvalidInput, "secrets reencrypt: --yes and --dry-run cannot be combined", nil) + } + identity, selected, err := service.resolveAndSelect(request) + if err != nil { + return Result{}, err + } + service.deps.Secrets.SetDirectory(identity.Root) + result := Result{Items: make([]Item, 0, len(selected))} + var failures []error + for _, source := range selected { + status, err := service.reencryptOne(ctx, identity, source, request.Yes) + if err != nil { + status = "failed" + failures = append(failures, err) + } + result.Items = append(result.Items, itemOf(source, status)) + } + if len(failures) > 0 { + return result, errors.Join(failures...) + } + if !request.Yes && len(selected) > 0 { + return result, failure.New(failure.Difference, "secrets reencrypt: changes pending", nil) + } + return result, nil +} + +func (service *Service) reencryptOne(ctx context.Context, identity applicationrepository.RepositoryIdentity, selected selectedSource, publish bool) (string, error) { + ciphertext, frozen, err := readSource(identity.Root, selected) + if err != nil { + return "failed", itemFailure("reencrypt", selected, err) + } + defer clearBytes(ciphertext) + + reencrypted, err := service.rotate(ctx, selected, ciphertext) + if err != nil { + return "failed", err + } + defer clearBytes(reencrypted) + if !publish { + return "planned", nil + } + + mode := frozen.Target().Mode() + published, err := service.deps.Writer.ReplaceResult(ctx, frozen, filesystem.ReplacementSpec{Content: reencrypted, Mode: mode}) + if err != nil || !published.Renamed || !published.DirectorySynced { + return "failed", itemFailure("reencrypt", selected, err) + } + if _, err := service.deps.Baselines.RefreshSecretSourceHash(identity.Root, identity.Home, + selected.source.Candidate.SourceRepoPath, deployment.RawStorage(reencrypted)); err != nil { + return "failed", itemFailure("refresh state", selected, err) + } + return "reencrypted", nil +} + +func (service *Service) rotate(ctx context.Context, selected selectedSource, ciphertext []byte) ([]byte, error) { + path := selected.source.Candidate.SourceRepoPath + plaintext, err := service.deps.Secrets.Decrypt(ctx, ciphertext, path) + if err != nil { + return nil, itemFailure("reencrypt", selected, err) + } + defer clearBytes(plaintext) + + reencrypted, err := service.deps.Secrets.Encrypt(ctx, plaintext, path) + if err != nil { + return nil, itemFailure("reencrypt", selected, err) + } + validated, err := service.deps.Secrets.Decrypt(ctx, reencrypted, path) + if err != nil { + clearBytes(reencrypted) + return nil, itemFailure("validate", selected, err) + } + equal := bytes.Equal(plaintext, validated) + clearBytes(validated) + if !equal { + clearBytes(reencrypted) + return nil, itemFailure("validate", selected, errors.New("round-trip plaintext mismatch")) + } + return reencrypted, nil +} + +func readSource(root string, selected selectedSource) ([]byte, filesystem.Precondition, error) { + path := selected.source.Candidate.SourceRepoPath + frozen, err := filesystem.Freeze(filesystem.Destination{Root: root, Relative: path}) + if err != nil { + return nil, frozen, err + } + content, err := filesystem.ReadFrozen(frozen) + return content, frozen, err +} + +func resultWithStatus(selected []selectedSource, status string) Result { + result := Result{Items: make([]Item, 0, len(selected))} + for _, source := range selected { + result.Items = append(result.Items, itemOf(source, status)) + } + return result +} + +func itemFailure(operation string, selected selectedSource, cause error) error { + path := selected.source.Candidate.SourceRepoPath + if cause == nil { + cause = fs.ErrInvalid + } + kind, ok := failure.HasKind(cause) + if !ok { + kind = failure.Operational + } + return failure.New(kind, fmt.Sprintf("secrets %s: %q", operation, path), cause) +} + +func clearBytes(content []byte) { + for index := range content { + content[index] = 0 + } +} diff --git a/internal/application/secretlifecycle/service_test.go b/internal/application/secretlifecycle/service_test.go new file mode 100644 --- /dev/null +++ b/internal/application/secretlifecycle/service_test.go @@ -0,0 +1,253 @@ +package secretlifecycle + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + applicationrepository "github.com/alyraffauf/cattery/internal/application/repository" + "github.com/alyraffauf/cattery/internal/deployment" + "github.com/alyraffauf/cattery/internal/failure" + "github.com/alyraffauf/cattery/internal/filesystem" + "github.com/alyraffauf/cattery/internal/selection" +) + +func TestLifecycleSelectionIncludesEveryLayerAndUsesUnion(t *testing.T) { + stage := newLifecycleStage(t) + stage.write(t, "_secrets/root", "root") + stage.write(t, "_linux/_secrets/linux", "linux") + stage.write(t, "apps/_secrets/base", "base") + stage.write(t, "apps/_darwin/_secrets/mac", "mac") + stage.write(t, "tools/_secrets/tool", "tool") + + result, err := stage.service.List(context.Background(), Request{Groups: []string{"apps"}, Sources: []string{"_secrets/root"}}) + if err != nil { + t.Fatal(err) + } + if got := itemSources(result); strings.Join(got, ",") != "_secrets/root,apps/_darwin/_secrets/mac,apps/_secrets/base" { + t.Fatalf("sources = %v", got) + } + if len(stage.secrets.decryptPaths) != 0 || len(stage.secrets.encryptPaths) != 0 { + t.Fatal("list invoked SOPS") + } +} + +func TestLifecycleRejectsInvalidSelectors(t *testing.T) { + stage := newLifecycleStage(t) + stage.write(t, "apps/_secrets/token", "ciphertext") + stage.write(t, "apps/config", "ordinary") + cases := []Request{ + {Groups: []string{"missing"}}, + {Sources: []string{"apps/_secrets/token", "apps/_secrets/token"}}, + {Sources: []string{"apps/config"}}, + {Sources: []string{"missing/_secrets/token"}}, + } + for _, request := range cases { + if _, err := stage.service.List(context.Background(), request); !hasKind(err, failure.InvalidInput) { + t.Fatalf("request %+v error = %v", request, err) + } + } +} + +func TestVerifyContinuesAfterIndependentFailure(t *testing.T) { + stage := newLifecycleStage(t) + stage.write(t, "_secrets/bad", "bad") + stage.write(t, "_secrets/good", "good") + stage.secrets.failDecrypt["_secrets/bad"] = true + + result, err := stage.service.Verify(context.Background(), Request{}) + if err == nil || len(result.Items) != 2 { + t.Fatalf("result = %+v, error = %v", result, err) + } + if result.Items[0].Status != "failed" || result.Items[1].Status != "verified" { + t.Fatalf("statuses = %+v", result.Items) + } + if strings.Contains(err.Error(), "plaintext") { + t.Fatalf("error leaked plaintext: %v", err) + } +} + +func TestReencryptPreviewAndPublish(t *testing.T) { + stage := newLifecycleStage(t) + stage.writeMode(t, "apps/_secrets/token", "old", 0o740) + + preview, err := stage.service.Reencrypt(context.Background(), Request{}) + if !hasKind(err, failure.Difference) || preview.Items[0].Status != "planned" { + t.Fatalf("preview = %+v, error = %v", preview, err) + } + stage.requireContent(t, "apps/_secrets/token", "old") + + result, err := stage.service.Reencrypt(context.Background(), Request{Yes: true}) + if err != nil || result.Items[0].Status != "reencrypted" { + t.Fatalf("publish = %+v, error = %v", result, err) + } + stage.requireContent(t, "apps/_secrets/token", "new:plaintext") + info, _ := os.Stat(filepath.Join(stage.root, "apps/_secrets/token")) + if info.Mode().Perm() != 0o740 { + t.Fatalf("mode = %o", info.Mode().Perm()) + } + if stage.baselines.source != "apps/_secrets/token" || stage.baselines.hash != deployment.RawStorage([]byte("new:plaintext")) { + t.Fatalf("baseline refresh = %+v", stage.baselines) + } + if strings.Join(stage.secrets.encryptPaths, ",") != "apps/_secrets/token,apps/_secrets/token" { + t.Fatalf("filename overrides = %v", stage.secrets.encryptPaths) + } +} + +func TestReencryptContinuesAndRejectsConflictingFlags(t *testing.T) { + stage := newLifecycleStage(t) + stage.write(t, "_secrets/bad", "bad") + stage.write(t, "_secrets/good", "good") + stage.secrets.failEncrypt["_secrets/bad"] = true + + result, err := stage.service.Reencrypt(context.Background(), Request{Yes: true}) + if err == nil || result.Items[0].Status != "failed" || result.Items[1].Status != "reencrypted" { + t.Fatalf("result = %+v, error = %v", result, err) + } + stage.requireContent(t, "_secrets/bad", "bad") + stage.requireContent(t, "_secrets/good", "new:plaintext") + if _, err := stage.service.Reencrypt(context.Background(), Request{Yes: true, DryRun: true}); !hasKind(err, failure.InvalidInput) { + t.Fatalf("flag error = %v", err) + } +} + +func TestReencryptRoundTripAndPublicationFailuresLeaveSource(t *testing.T) { + stage := newLifecycleStage(t) + stage.write(t, "_secrets/token", "old") + stage.secrets.mismatchValidation = true + result, err := stage.service.Reencrypt(context.Background(), Request{Yes: true}) + if err == nil || result.Items[0].Status != "failed" { + t.Fatalf("round-trip result = %+v, error = %v", result, err) + } + stage.requireContent(t, "_secrets/token", "old") + + stage.secrets.mismatchValidation = false + stage.service = NewService(Dependencies{ + RepositorySource: fixedRepository{identity: applicationrepository.RepositoryIdentity{Root: stage.root, Home: stage.home}}, + Secrets: stage.secrets, Writer: failingWriter{}, Baselines: stage.baselines, + }) + result, err = stage.service.Reencrypt(context.Background(), Request{Yes: true}) + if err == nil || result.Items[0].Status != "failed" { + t.Fatalf("publication result = %+v, error = %v", result, err) + } + stage.requireContent(t, "_secrets/token", "old") +} + +type lifecycleStage struct { + root string + home string + service *Service + secrets *fakeSecrets + baselines *fakeBaselines +} + +func newLifecycleStage(t *testing.T) *lifecycleStage { + t.Helper() + root, home := t.TempDir(), t.TempDir() + secretClient := &fakeSecrets{failDecrypt: map[string]bool{}, failEncrypt: map[string]bool{}} + baselines := &fakeBaselines{} + service := NewService(Dependencies{ + RepositorySource: fixedRepository{identity: applicationrepository.RepositoryIdentity{Root: root, Home: home}}, + Secrets: secretClient, Writer: filesystem.NewReplacer(), Baselines: baselines, + }) + return &lifecycleStage{root: root, home: home, service: service, secrets: secretClient, baselines: baselines} +} + +func (stage *lifecycleStage) write(t *testing.T, relative, content string) { + t.Helper() + stage.writeMode(t, relative, content, 0o600) +} + +func (stage *lifecycleStage) writeMode(t *testing.T, relative, content string, mode os.FileMode) { + t.Helper() + path := filepath.Join(stage.root, filepath.FromSlash(relative)) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), mode); err != nil { + t.Fatal(err) + } +} + +func (stage *lifecycleStage) requireContent(t *testing.T, relative, want string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(stage.root, filepath.FromSlash(relative))) + if err != nil || string(content) != want { + t.Fatalf("content = %q, %v; want %q", content, err, want) + } +} + +type fixedRepository struct { + identity applicationrepository.RepositoryIdentity +} + +func (source fixedRepository) Resolve(selection.RepositoryRequest) (applicationrepository.RepositoryIdentity, error) { + return source.identity, nil +} + +type fakeSecrets struct { + failDecrypt map[string]bool + failEncrypt map[string]bool + decryptPaths []string + encryptPaths []string + mismatchValidation bool +} + +func (client *fakeSecrets) SetDirectory(string) {} + +func (client *fakeSecrets) Decrypt(_ context.Context, ciphertext []byte, path string) ([]byte, error) { + client.decryptPaths = append(client.decryptPaths, path) + if client.failDecrypt[path] { + return nil, failure.New(failure.Operational, "sops decrypt "+path+" failed", nil) + } + if strings.HasPrefix(string(ciphertext), "new:") { + if client.mismatchValidation { + return []byte("different plaintext"), nil + } + return []byte(strings.TrimPrefix(string(ciphertext), "new:")), nil + } + return []byte("plaintext"), nil +} + +type failingWriter struct{} + +func (failingWriter) ReplaceResult(context.Context, filesystem.Precondition, filesystem.ReplacementSpec) (filesystem.ReplaceResult, error) { + return filesystem.ReplaceResult{}, errors.New("injected publication failure") +} + +func (client *fakeSecrets) Encrypt(_ context.Context, plaintext []byte, path string) ([]byte, error) { + client.encryptPaths = append(client.encryptPaths, path) + if client.failEncrypt[path] { + return nil, failure.New(failure.Operational, "sops encrypt "+path+" failed", nil) + } + return append([]byte("new:"), plaintext...), nil +} + +type fakeBaselines struct { + source string + hash deployment.Digest +} + +func (store *fakeBaselines) RefreshSecretSourceHash(_, _, source string, hash deployment.Digest) (bool, error) { + store.source, store.hash = source, hash + return true, nil +} + +func itemSources(result Result) []string { + paths := make([]string, 0, len(result.Items)) + for _, item := range result.Items { + paths = append(paths, item.Source) + } + return paths +} + +func hasKind(err error, want failure.Kind) bool { + if err == nil { + return false + } + kind, ok := failure.HasKind(err) + return ok && kind == want +} diff --git a/internal/application/secretlifecycle/types.go b/internal/application/secretlifecycle/types.go new file mode 100644 --- /dev/null +++ b/internal/application/secretlifecycle/types.go @@ -0,0 +1,73 @@ +// Package secretlifecycle implements safe inventory, verification, and +// recipient/configuration rotation for managed encrypted sources. +package secretlifecycle + +import ( + "context" + + applicationrepository "github.com/alyraffauf/cattery/internal/application/repository" + "github.com/alyraffauf/cattery/internal/deployment" + "github.com/alyraffauf/cattery/internal/filesystem" + "github.com/alyraffauf/cattery/internal/selection" +) + +type RepositoryInput = applicationrepository.RepositoryInput + +// Request carries the union selectors and re-encryption authorization flags. +type Request struct { + Repository RepositoryInput + Groups []string + Sources []string + DryRun bool + Yes bool +} + +// Item is safe metadata for one encrypted repository source. +type Item struct { + Source string + Target string + Group string + Layer deployment.Layer + Kind deployment.FileKind + Status string +} + +// Result records every selected item, including independent failures. +type Result struct { + Items []Item +} + +// SecretClient is the bounded, repository-pinned SOPS role. +type SecretClient interface { + SetDirectory(string) + Decrypt(context.Context, []byte, string) ([]byte, error) + Encrypt(context.Context, []byte, string) ([]byte, error) +} + +// AtomicWriter publishes exact bytes over a frozen source. +type AtomicWriter interface { + ReplaceResult(context.Context, filesystem.Precondition, filesystem.ReplacementSpec) (filesystem.ReplaceResult, error) +} + +// SourceBaselineRefresher updates only an active baseline that names a source. +type SourceBaselineRefresher interface { + RefreshSecretSourceHash(root, home, source string, hash deployment.Digest) (bool, error) +} + +type Dependencies struct { + RepositorySource applicationrepository.RepositorySource + Secrets SecretClient + Writer AtomicWriter + Baselines SourceBaselineRefresher +} + +type Service struct{ deps Dependencies } + +func NewService(dependencies Dependencies) *Service { return &Service{deps: dependencies} } + +func repositoryRequest(input RepositoryInput) selection.RepositoryRequest { + return selection.RepositoryRequest{ + RawExplicit: input.RawExplicit, ExplicitSet: input.ExplicitSet, + RawEnv: input.RawEnv, EnvSet: input.EnvSet, WorkingDir: input.WorkingDir, + } +} -- tangled.sh