diff --git a/internal/application/apply/source_guard.go b/internal/application/apply/source_guard.go new file mode 100644 index 0000000..d676ed5 --- /dev/null +++ b/internal/application/apply/source_guard.go @@ -0,0 +1,79 @@ +package apply + +import ( + "context" + + "github.com/alyraffauf/cattery/internal/failure" + "github.com/alyraffauf/cattery/internal/pathsafe" + "github.com/alyraffauf/cattery/internal/reconcile" +) + +// Revalidate re-captures every selected source and target after before +// hooks and compares each against the evaluated facts (PLAN.md Section +// 11.5). Any mismatch stops the apply with zero executor or managed-row +// change. +func (service *Service) Revalidate(ctx context.Context, candidates Candidates) error { + if err := ctx.Err(); err != nil { + return err + } + for _, candidate := range candidates.All() { + if err := service.revalidateOne(candidate, candidates.Home()); err != nil { + return err + } + } + return nil +} + +// revalidateOne re-captures one candidate's source and target preconditions. +func (service *Service) revalidateOne(candidate Candidate, home string) error { + if candidate.record.Entry == reconcile.PlanEntryFile { + fresh, err := reconcile.CaptureSource(candidate.record.File, service.secrets) + if err != nil { + return failure.New(failure.Operational, "apply: re-capture source "+candidate.record.File.SourceRepositoryPath, err) + } + if err := sourceStable(candidate, fresh.Snapshot()); err != nil { + return err + } + } + target, err := reconcile.CaptureTarget(reconcile.Destination{Root: home, Relative: candidate.record.TargetPath}) + if err != nil { + return failure.New(failure.Operational, "apply: re-capture target "+candidate.record.TargetPath, err) + } + return targetStable(candidate, target) +} + +// sourceStable requires the re-captured source to match the evaluated +// facts exactly: identity, type, stored bytes, and executable bits. +func sourceStable(candidate Candidate, fresh reconcile.SourceSnapshot) error { + before := candidate.record.Source.Snapshot() + switch { + case !pathsafe.SameIdentity(fresh.Identity(), before.Identity()): + return failure.New(failure.Operational, "apply: source identity changed during apply: "+before.Path(), nil) + case fresh.Kind() != before.Kind(): + return failure.New(failure.Operational, "apply: source type changed during apply: "+before.Path(), nil) + case fresh.Semantic() != before.Semantic() || fresh.Storage() != before.Storage(): + return failure.New(failure.Operational, "apply: source content changed during apply: "+before.Path(), nil) + case fresh.Executable() != before.Executable(): + return failure.New(failure.Operational, "apply: source mode changed during apply: "+before.Path(), nil) + } + return nil +} + +// targetStable requires the re-captured target to match the evaluated facts +// exactly: identity, kind, parent identity, and for regular files the +// stored bytes and mode. +func targetStable(candidate Candidate, fresh reconcile.TargetSnapshot) error { + before := candidate.record.Target + path := candidate.record.TargetPath + switch { + case fresh.Kind() != before.Kind(): + return failure.New(failure.Operational, "apply: target type changed during apply: "+path, nil) + case !pathsafe.SameIdentity(fresh.Identity(), before.Identity()): + return failure.New(failure.Operational, "apply: target identity changed during apply: "+path, nil) + case !pathsafe.SameIdentity(fresh.Parent(), before.Parent()): + return failure.New(failure.Operational, "apply: target parent changed during apply: "+path, nil) + case fresh.Kind() == reconcile.KindFile && (fresh.Digest() != before.Digest() || fresh.Mode() != before.Mode()): + return failure.New(failure.Operational, "apply: target content changed during apply: "+path, nil) + } + return nil +} diff --git a/internal/application/apply/source_guard_test.go b/internal/application/apply/source_guard_test.go new file mode 100644 index 0000000..5178c55 --- /dev/null +++ b/internal/application/apply/source_guard_test.go @@ -0,0 +1,161 @@ +package apply + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/alyraffauf/cattery/internal/deployment" + "github.com/alyraffauf/cattery/internal/failure" + "github.com/alyraffauf/cattery/internal/state" +) + +func TestApplySourceGuard(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"ordinary storage", testGuardOrdinary}, + {"secret storage", testGuardSecret}, + {"source mode", testGuardSourceMode}, + {"target identity", testGuardTargetIdentity}, + {"parent identity", testGuardParent}, + {"hook edit", testGuardHookEdit}, + {"target race", testGuardTargetRace}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +// guardFixture evaluates one ordinary file pair and returns the service, +// the candidates, and the paths. +func guardFixture(t *testing.T) (*Service, Candidates, string, string) { + t.Helper() + repo := t.TempDir() + home := t.TempDir() + ordinarySource(t, fileSpec{Repo: repo, Target: "a.conf", Relative: "files/a"}, []byte("source")) + writeTarget(t, targetPath(home, "a.conf"), []byte("target")) + service := evalFixture(t, evalInput{repo: repo, home: home, plan: evalPlan(t, repo, planFile(t, fileSpec{Repo: repo, Target: "a.conf", Relative: "files/a"}))}) + candidates, err := service.Evaluate(context.Background(), evalRequest()) + if err != nil { + t.Fatalf("evaluate: %v", err) + } + return service, candidates, repo, home +} + +// planFile freezes one ordinary plan entry over the spec. +func planFile(t *testing.T, spec fileSpec) deployment.ManagedFile { + t.Helper() + path := filepath.Join(spec.Repo, filepath.FromSlash(spec.Relative)) + file, err := deployment.NewManagedFile(deployment.ManagedFile{ + Scope: deployment.NewScope(""), Layer: deployment.LayerBase, Kind: deployment.FileOrdinary, + SourceAbsolutePath: path, SourceRepositoryPath: spec.Relative, TargetRelativePath: spec.Target, + }) + if err != nil { + t.Fatalf("managed file: %v", err) + } + return file +} + +// requireGuardStable requires revalidation to pass. +func requireGuardStable(t *testing.T, service *Service, candidates Candidates) { + t.Helper() + if err := service.Revalidate(context.Background(), candidates); err != nil { + t.Fatalf("revalidate: %v", err) + } +} + +// requireGuardMismatch requires revalidation to fail operationally. +func requireGuardMismatch(t *testing.T, service *Service, candidates Candidates) { + t.Helper() + err := service.Revalidate(context.Background(), candidates) + if err == nil || !kindIs(err, failure.Operational) { + t.Fatalf("revalidate error = %v, want an operational failure", err) + } +} + +func testGuardOrdinary(t *testing.T) { + service, candidates, _, home := guardFixture(t) + requireGuardStable(t, service, candidates) + if err := os.WriteFile(targetPath(home, "a.conf"), []byte("mutated"), 0o600); err != nil { + t.Fatal(err) + } + requireGuardMismatch(t, service, candidates) +} + +func testGuardSecret(t *testing.T) { + repo := t.TempDir() + home := t.TempDir() + file := secretSource(t, fileSpec{Repo: repo, Target: "target", Relative: "files/token"}) + rows := stateRows{files: []state.FileBaseline{{ + TargetPath: "target", SourcePath: "files/token", SourceKind: deployment.FileSecret, Layer: deployment.LayerBase, + BaselineContentHash: deployment.SecretSemantic([]byte("secret"), [32]byte{7}), + BaselineSourceHash: deployment.RawStorage([]byte(`{"data":"c2VjcmV0","sops":{"version":"3.9.0"}}`)), + Status: state.StatusActive, + }}} + writeTarget(t, targetPath(home, "target"), []byte("secret")) + service := evalFixture(t, evalInput{repo: repo, home: home, plan: evalPlan(t, repo, file), rows: rows}) + candidates, err := service.Evaluate(context.Background(), evalRequest()) + if err != nil { + t.Fatalf("evaluate: %v", err) + } + requireGuardStable(t, service, candidates) + envelope := []byte(`{"data":"Y2hhbmdlZA==","sops":{"version":"3.9.0"}}`) + if err := os.WriteFile(file.SourceAbsolutePath, envelope, 0o600); err != nil { + t.Fatal(err) + } + requireGuardMismatch(t, service, candidates) +} + +func testGuardSourceMode(t *testing.T) { + service, candidates, repo, _ := guardFixture(t) + if err := os.Chmod(filepath.Join(repo, "files", "a"), 0o755); err != nil { + t.Fatal(err) + } + requireGuardMismatch(t, service, candidates) +} + +func testGuardTargetIdentity(t *testing.T) { + service, candidates, _, home := guardFixture(t) + path := targetPath(home, "a.conf") + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := os.Symlink("files/a", path); err != nil { + t.Fatal(err) + } + requireGuardMismatch(t, service, candidates) +} + +func testGuardParent(t *testing.T) { + service, candidates, _, home := guardFixture(t) + parent := filepath.Dir(targetPath(home, "a.conf")) + backup := parent + "-backup" + if err := os.Rename(parent, backup); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(parent, 0o700); err != nil { + t.Fatal(err) + } + writeTarget(t, targetPath(home, "a.conf"), []byte("target")) + requireGuardMismatch(t, service, candidates) + _ = backup +} + +func testGuardHookEdit(t *testing.T) { + service, candidates, repo, _ := guardFixture(t) + if err := os.WriteFile(filepath.Join(repo, "files", "a"), []byte("hook edit"), 0o600); err != nil { + t.Fatal(err) + } + requireGuardMismatch(t, service, candidates) +} + +func testGuardTargetRace(t *testing.T) { + service, candidates, _, home := guardFixture(t) + if err := os.Remove(targetPath(home, "a.conf")); err != nil { + t.Fatal(err) + } + requireGuardMismatch(t, service, candidates) +}