diff --git a/internal/application/add/service.go b/internal/application/add/service.go new file mode 100644 index 0000000..d2dcc89 --- /dev/null +++ b/internal/application/add/service.go @@ -0,0 +1,33 @@ +package add + +import ( + "runtime" + + "github.com/alyraffauf/cattery/internal/deployment" +) + +// Service performs one add batch against the injectable ports. Construction +// is side-effect-free: every repository, filesystem, secret, and state effect +// happens inside Add. The runtime platform layer is read once at +// construction via runtime.GOOS so an explicit --platform must equal it. +type Service struct { + deps Dependencies + platform deployment.Layer +} + +// NewService binds the dependencies and resolves the runtime platform layer +// (linux or darwin on a supported host; the zero layer otherwise, which Add +// rejects). +func NewService(deps Dependencies) *Service { + return &Service{deps: deps, platform: runtimeLayer()} +} + +// runtimeLayer resolves the host platform layer, returning the zero layer +// when the host is neither linux nor darwin. +func runtimeLayer() deployment.Layer { + layer, err := deployment.ParseLayer(runtime.GOOS) + if err != nil { + return "" + } + return layer +} diff --git a/internal/application/add/write_ordinary.go b/internal/application/add/write_ordinary.go new file mode 100644 index 0000000..8549e6e --- /dev/null +++ b/internal/application/add/write_ordinary.go @@ -0,0 +1,95 @@ +package add + +import ( + "context" + "io" + "io/fs" + "os" + + "github.com/alyraffauf/cattery/internal/failure" + "github.com/alyraffauf/cattery/internal/filesystem" +) + +// writeOutcome carries the published source content and the pre-write target +// content of one source replacement. For an ordinary write both fields hold +// the target bytes; for a secret write published holds the ciphertext and +// target holds the plaintext the caller verifies and then clears. +type writeOutcome struct { + published []byte + target []byte + result filesystem.ReplaceResult +} + +// writeOrdinary atomically writes one ordinary source from freshly validated +// target bytes. The source mode follows Section 7.1: 0644 plus exec bits when +// the source is new, or the preserved read/write bits plus exec bits when it +// already exists. +func (service *Service) writeOrdinary(ctx context.Context, identity RepositoryIdentity, item ItemPlan) (writeOutcome, error) { + precondition, err := filesystem.Freeze(filesystem.Destination{Root: identity.Root, Relative: item.SourceRepositoryPath()}) + if err != nil { + return writeOutcome{}, freezeError(item, err) + } + target, err := readValidatedTarget(item.TargetAbsolutePath()) + if err != nil { + return writeOutcome{}, err + } + mode := sourceMode(precondition.Target(), item.ExecutableBits()) + result, err := service.deps.Writer.ReplaceResult(ctx, precondition, filesystem.ReplacementSpec{Content: target, Mode: mode}) + if err != nil { + return writeOutcome{}, err + } + return writeOutcome{published: target, target: target, result: result}, nil +} + +// sourceMode applies Section 7.1 to the frozen source: a new source takes +// 0644 plus exec bits; an existing source preserves its read/write bits. +func sourceMode(target filesystem.TargetFacts, exec fs.FileMode) fs.FileMode { + if target.Kind() == filesystem.KindAbsent { + return filesystem.OrdinaryTargetMode(0, exec, true) + } + return filesystem.OrdinaryTargetMode(target.Mode(), exec, false) +} + +// readValidatedTarget opens one target, confirms it is a regular file, reads +// it fully, and re-confirms the descriptor is unchanged. This closes the gap +// between preflight's snapshot and the write. +func readValidatedTarget(absolute string) ([]byte, error) { + handle, err := os.Open(absolute) + if err != nil { + return nil, failure.New(failure.Operational, "add: open target "+absolute, err) + } + defer handle.Close() + before, err := handle.Stat() + if err != nil { + return nil, failure.New(failure.Operational, "add: stat target "+absolute, err) + } + if !before.Mode().IsRegular() { + return nil, failure.New(failure.InvalidInput, "add: target "+absolute+" is not a regular file", nil) + } + content, err := io.ReadAll(handle) + if err != nil { + return nil, failure.New(failure.Operational, "add: read target "+absolute, err) + } + if err := confirmTargetStable(handle, before, absolute); err != nil { + return nil, err + } + return content, nil +} + +// confirmTargetStable re-stats the open descriptor and rejects a target that +// changed kind or identity between the read and the re-stat. +func confirmTargetStable(handle *os.File, before os.FileInfo, absolute string) error { + after, err := handle.Stat() + if err != nil { + return failure.New(failure.Operational, "add: re-stat target "+absolute, err) + } + if !os.SameFile(before, after) || !after.Mode().IsRegular() { + return failure.New(failure.Operational, "add: target "+absolute+" changed while reading", nil) + } + return nil +} + +// freezeError wraps a source freeze failure with the repository-relative path. +func freezeError(item ItemPlan, err error) error { + return failure.New(failure.Operational, "add: freeze source "+item.SourceRepositoryPath(), err) +} diff --git a/internal/application/add/write_ordinary_test.go b/internal/application/add/write_ordinary_test.go new file mode 100644 index 0000000..84676c8 --- /dev/null +++ b/internal/application/add/write_ordinary_test.go @@ -0,0 +1,126 @@ +package add + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/alyraffauf/cattery/internal/deployment" + "github.com/alyraffauf/cattery/internal/filesystem" + testfs "github.com/alyraffauf/cattery/internal/testfixture/filesystem" +) + +func TestAddOrdinaryWrite(t *testing.T) { + scenarios := []struct { + name string + run func(*testing.T) + }{ + {"writes new source with content", testWriteNewSource}, + {"preserves existing source mode", testWritePreservesMode}, + {"records executable bits", testWriteExecutableBits}, + } + for _, scenario := range scenarios { + t.Run(scenario.name, scenario.run) + } +} + +func testWriteNewSource(t *testing.T) { + state := newWriteStage(t, ordinaryTarget{relative: ".bashrc", content: []byte("shell"), mode: 0o600}) + outcome, err := state.service.writeOrdinary(context.Background(), state.identity, state.item) + if err != nil { + t.Fatal(err) + } + assertSourceContent(t, state.repoSource(), []byte("shell")) + if outcome.result.Renamed != true { + t.Fatal("write did not publish the source") + } +} + +func testWritePreservesMode(t *testing.T) { + target := ordinaryTarget{relative: ".bashrc", content: []byte("new"), mode: 0o600} + state := newWriteStage(t, target) + if err := os.WriteFile(state.repoSource(), []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := state.service.writeOrdinary(context.Background(), state.identity, state.item); err != nil { + t.Fatal(err) + } + assertSourceMode(t, state.repoSource(), 0o600) +} + +func testWriteExecutableBits(t *testing.T) { + target := ordinaryTarget{relative: "bin/tool", content: []byte("tool"), mode: 0o755} + state := newWriteStage(t, target) + if _, err := state.service.writeOrdinary(context.Background(), state.identity, state.item); err != nil { + t.Fatal(err) + } + assertSourceMode(t, state.repoSource(), 0o755) +} + +// ordinaryTarget describes the target file materialized beneath home. +type ordinaryTarget struct { + relative string + content []byte + mode os.FileMode +} + +// writeStage bundles the service, identity, item, and source path of one +// ordinary-write test. +type writeStage struct { + service *Service + identity RepositoryIdentity + item ItemPlan + repo string +} + +func newWriteStage(t *testing.T, target ordinaryTarget) writeStage { + t.Helper() + home := t.TempDir() + repo := t.TempDir() + if err := testfs.New(home).File(target.relative, target.content, target.mode).Materialize(); err != nil { + t.Fatal(err) + } + item, err := NewItemPlan(ItemPlanInput{ + Layer: deployment.LayerBase, Kind: deployment.FileOrdinary, + TargetAbsolutePath: filepath.Join(home, target.relative), + TargetRelativePath: target.relative, + SourceRepositoryPath: target.relative, + SourceAbsolutePath: filepath.Join(repo, target.relative), + ExecutableBits: target.mode & deployment.ExecutableBitMask, + }) + if err != nil { + t.Fatal(err) + } + return writeStage{ + service: NewService(Dependencies{Writer: filesystem.NewReplacer()}), + identity: RepositoryIdentity{Root: repo, Home: home}, + item: item, repo: repo, + } +} + +func (stage writeStage) repoSource() string { + return filepath.Join(stage.identity.Root, stage.item.SourceRepositoryPath()) +} + +func assertSourceContent(t *testing.T, path string, want []byte) { + t.Helper() + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != string(want) { + t.Fatalf("source content = %q, want %q", got, want) + } +} + +func assertSourceMode(t *testing.T, path string, want os.FileMode) { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != want { + t.Fatalf("source mode = %v, want %v", info.Mode().Perm(), want) + } +}