Files
tapir/internal/adapters/secrets/file.go
T
mathiasandClaude Sonnet 4.6 38f222c931
CI / Lint / Test / Vet (push) Successful in 11s
CI / Build & Import (push) Successful in 12s
chore: rename Go module path gitea.d-ma.be → git.d-ma.be
Infra ADR-0004 renamed the Gitea host. Bulk replace across go.mod and
all .go import paths. Build and tests pass unchanged.

Closes #20

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dt6aHEDWRjkK14Voi6HnGh
2026-07-02 14:37:33 +02:00

145 lines
4.1 KiB
Go

// 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"
"git.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
}
// Delete removes the secret stored under ref, persisting the file atomically
// (temp file + rename) with 0600 permissions. Deleting an absent ref — or one in
// a file that does not exist yet — is a no-op, not an error. Used by account
// management (disconnect / delete-account) to purge a user's OAuth tokens.
func (s *FileStore) Delete(ref string) error {
s.mu.Lock()
defer s.mu.Unlock()
m, err := s.load()
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil // nothing to delete
}
return err
}
if _, ok := m[ref]; !ok {
return nil // already absent
}
delete(m, ref)
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
}