From c93b433aafe681196f53d67123c81c7ca956fca4 Mon Sep 17 00:00:00 2001 From: Mathias Date: Tue, 2 Jun 2026 20:57:44 +0200 Subject: [PATCH] feat(secrets): file-backed SecretStore for Stage-0 Implements ports.SecretStore over a 0600 JSON file as a stand-in for op/ESO so the demo runs without live op. Put persists atomically (temp + rename) and merges; Get returns ErrNotFound for unknown refs so a missing token fails loud. Behind the port, so swapping to op/ESO later is wiring, not code (ADR-002). Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/adapters/secrets/file.go | 107 +++++++++++++++++++++++++ internal/adapters/secrets/file_test.go | 72 +++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 internal/adapters/secrets/file.go create mode 100644 internal/adapters/secrets/file_test.go diff --git a/internal/adapters/secrets/file.go b/internal/adapters/secrets/file.go new file mode 100644 index 0000000..1b90124 --- /dev/null +++ b/internal/adapters/secrets/file.go @@ -0,0 +1,107 @@ +// Package secrets provides a local file-backed implementation of the +// ports.SecretStore port. It is a Stage-0 stand-in for op/ESO: secret material +// (the YouTube OAuth refresh token) is kept in a 0600 JSON file rather than the +// vault, so the demo runs without live op. Because every consumer depends on +// the SecretStore port, swapping this for an op/ESO-backed store later is a +// wiring change, not a code change (ADR-002, docs/homelab-integration.md). +// +// Secret values are never logged. Get returns an error for an unknown ref so a +// missing token surfaces loudly rather than as an empty string. +package secrets + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + + "gitea.d-ma.be/mathias/tapir/internal/ports" +) + +// FileStore is a SecretStore backed by a single 0600 JSON file mapping opaque +// refs to secret values. Safe for concurrent use within one process. +type FileStore struct { + path string + mu sync.RWMutex +} + +// Static check: FileStore satisfies the read side of the port. +var _ ports.SecretStore = (*FileStore)(nil) + +// ErrNotFound is returned by Get when no secret is stored under the ref. +var ErrNotFound = errors.New("secrets: ref not found") + +// NewFileStore returns a store backed by path. The file need not exist yet; it +// is created on the first Put. +func NewFileStore(path string) *FileStore { + return &FileStore{path: path} +} + +// Get resolves a ref to its secret value. It returns ErrNotFound if the file or +// the ref is absent. +func (s *FileStore) Get(_ context.Context, ref string) (string, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + m, err := s.load() + if err != nil { + return "", err + } + v, ok := m[ref] + if !ok { + return "", fmt.Errorf("%w: %q", ErrNotFound, ref) + } + return v, nil +} + +// Put stores value under ref, persisting the file with 0600 permissions. It +// merges into any existing entries and writes atomically (temp file + rename). +func (s *FileStore) Put(ref, value string) error { + s.mu.Lock() + defer s.mu.Unlock() + + m, err := s.load() + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + if m == nil { + m = make(map[string]string) + } + m[ref] = value + + if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil { + return fmt.Errorf("secrets: create dir: %w", err) + } + b, err := json.Marshal(m) + if err != nil { + return fmt.Errorf("secrets: marshal: %w", err) + } + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, b, 0o600); err != nil { + return fmt.Errorf("secrets: write temp: %w", err) + } + if err := os.Rename(tmp, s.path); err != nil { + return fmt.Errorf("secrets: rename: %w", err) + } + return nil +} + +// load reads the backing file. A missing file yields an empty map (not an +// error) for Get's caller, except Put distinguishes os.ErrNotExist. +func (s *FileStore) load() (map[string]string, error) { + b, err := os.ReadFile(s.path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return map[string]string{}, nil + } + return nil, fmt.Errorf("secrets: read %s: %w", s.path, err) + } + var m map[string]string + if err := json.Unmarshal(b, &m); err != nil { + return nil, fmt.Errorf("secrets: parse %s: %w", s.path, err) + } + return m, nil +} diff --git a/internal/adapters/secrets/file_test.go b/internal/adapters/secrets/file_test.go new file mode 100644 index 0000000..1233383 --- /dev/null +++ b/internal/adapters/secrets/file_test.go @@ -0,0 +1,72 @@ +package secrets_test + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "gitea.d-ma.be/mathias/tapir/internal/adapters/secrets" +) + +func TestPutThenGet(t *testing.T) { + path := filepath.Join(t.TempDir(), "nested", "secrets.json") + s := secrets.NewFileStore(path) + + if err := s.Put("youtube/refresh_token", "rt-123"); err != nil { + t.Fatalf("Put: %v", err) + } + got, err := s.Get(context.Background(), "youtube/refresh_token") + if err != nil { + t.Fatalf("Get: %v", err) + } + if got != "rt-123" { + t.Errorf("Get = %q, want %q", got, "rt-123") + } +} + +func TestGetUnknownRef(t *testing.T) { + s := secrets.NewFileStore(filepath.Join(t.TempDir(), "secrets.json")) + _, err := s.Get(context.Background(), "missing") + if !errors.Is(err, secrets.ErrNotFound) { + t.Errorf("err = %v, want ErrNotFound", err) + } +} + +func TestPutIsOwnerOnly(t *testing.T) { + path := filepath.Join(t.TempDir(), "secrets.json") + s := secrets.NewFileStore(path) + if err := s.Put("k", "v"); err != nil { + t.Fatalf("Put: %v", err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatalf("Stat: %v", err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("file perm = %o, want 0600 (token must not be world-readable)", perm) + } +} + +func TestPutMergesEntries(t *testing.T) { + path := filepath.Join(t.TempDir(), "secrets.json") + s := secrets.NewFileStore(path) + if err := s.Put("a", "1"); err != nil { + t.Fatalf("Put a: %v", err) + } + if err := s.Put("b", "2"); err != nil { + t.Fatalf("Put b: %v", err) + } + // Re-open from disk to prove persistence, not in-memory state. + s2 := secrets.NewFileStore(path) + for k, want := range map[string]string{"a": "1", "b": "2"} { + got, err := s2.Get(context.Background(), k) + if err != nil { + t.Fatalf("Get %q: %v", k, err) + } + if got != want { + t.Errorf("Get %q = %q, want %q", k, got, want) + } + } +}