// Package storage is the cloud agnostic object store port for retained payloads (raw feed records // today, resumes and exports later). The interface keeps the core app free of any cloud SDK: a local // filesystem store backs development and the offline demo, and an S3 compatible store (MinIO, AWS // GovCloud S3, or Azure Blob behind the same shape) is wired at deploy. Choosing a backend is // configuration, not code (the cloud portability mandate). No payload key is ever trusted blindly: // every key is validated so a crafted source id can never write outside the store root. package storage import ( "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "os" "path" "path/filepath" "strings" "sync" ) // ErrNotFound is returned by Get when the key has no object. var ErrNotFound = errors.New("storage: object not found") // Storer is the minimal object store contract. Keys are forward slash separated logical paths; an // implementation maps them to its own layout (a directory tree for local, an object prefix for S3). // Implementations MUST reject any key that would escape their root. type Storer interface { Put(ctx context.Context, key string, data []byte) error Get(ctx context.Context, key string) ([]byte, error) Exists(ctx context.Context, key string) (bool, error) Delete(ctx context.Context, key string) error } // SHA256Hex returns the lowercase hex sha256 of data, used as the integrity stamp on a stored payload. func SHA256Hex(data []byte) string { sum := sha256.Sum256(data) return hex.EncodeToString(sum[:]) } // cleanKey validates a logical key and returns its slash separated clean form. It rejects an empty // key, an absolute key, and any key that climbs above the root, so a source supplied id can never // traverse out of the store. func cleanKey(key string) (string, error) { k := strings.TrimSpace(key) if k == "" { return "", errors.New("storage: empty key") } if strings.HasPrefix(k, "/") { return "", fmt.Errorf("storage: absolute key not allowed: %q", key) } cleaned := path.Clean(k) if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") { return "", fmt.Errorf("storage: key escapes root: %q", key) } return cleaned, nil } // LocalStore is a filesystem backed Storer rooted at a single directory. It is the default for // development and the offline demo. Writes are atomic (temp file then rename) so a crashed sync never // leaves a half written payload. type LocalStore struct{ root string } // NewLocal builds a LocalStore, creating the root directory if it does not exist. func NewLocal(root string) (*LocalStore, error) { r := strings.TrimSpace(root) if r == "" { return nil, errors.New("storage: local root is required") } abs, err := filepath.Abs(r) if err != nil { return nil, fmt.Errorf("storage: resolve root: %w", err) } if err := os.MkdirAll(abs, 0o750); err != nil { return nil, fmt.Errorf("storage: create root: %w", err) } return &LocalStore{root: abs}, nil } func (s *LocalStore) pathFor(key string) (string, error) { cleaned, err := cleanKey(key) if err != nil { return "", err } full := filepath.Join(s.root, filepath.FromSlash(cleaned)) // Defense in depth: even after cleaning, confirm the resolved path stays under the root. if full != s.root && !strings.HasPrefix(full, s.root+string(os.PathSeparator)) { return "", fmt.Errorf("storage: key escapes root: %q", key) } return full, nil } // Put writes data at key, creating parent directories as needed. The write is atomic. func (s *LocalStore) Put(_ context.Context, key string, data []byte) error { full, err := s.pathFor(key) if err != nil { return err } if err := os.MkdirAll(filepath.Dir(full), 0o750); err != nil { return fmt.Errorf("storage: mkdir: %w", err) } tmp := full + ".tmp" if err := os.WriteFile(tmp, data, 0o640); err != nil { return fmt.Errorf("storage: write: %w", err) } if err := os.Rename(tmp, full); err != nil { _ = os.Remove(tmp) return fmt.Errorf("storage: rename: %w", err) } return nil } // Get reads the object at key, returning ErrNotFound when it is absent. func (s *LocalStore) Get(_ context.Context, key string) ([]byte, error) { full, err := s.pathFor(key) if err != nil { return nil, err } b, err := os.ReadFile(full) if errors.Is(err, os.ErrNotExist) { return nil, ErrNotFound } if err != nil { return nil, fmt.Errorf("storage: read: %w", err) } return b, nil } // Exists reports whether an object is present at key. func (s *LocalStore) Exists(_ context.Context, key string) (bool, error) { full, err := s.pathFor(key) if err != nil { return false, err } _, err = os.Stat(full) if errors.Is(err, os.ErrNotExist) { return false, nil } if err != nil { return false, fmt.Errorf("storage: stat: %w", err) } return true, nil } // Delete removes the object at key. A missing object is not an error. func (s *LocalStore) Delete(_ context.Context, key string) error { full, err := s.pathFor(key) if err != nil { return err } if err := os.Remove(full); err != nil && !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("storage: delete: %w", err) } return nil } // MemoryStore is an in memory Storer for tests. It enforces the same key validation as LocalStore. type MemoryStore struct { mu sync.RWMutex m map[string][]byte } // NewMemory builds an empty in memory store. func NewMemory() *MemoryStore { return &MemoryStore{m: map[string][]byte{}} } func (s *MemoryStore) Put(_ context.Context, key string, data []byte) error { cleaned, err := cleanKey(key) if err != nil { return err } cp := make([]byte, len(data)) copy(cp, data) s.mu.Lock() s.m[cleaned] = cp s.mu.Unlock() return nil } func (s *MemoryStore) Get(_ context.Context, key string) ([]byte, error) { cleaned, err := cleanKey(key) if err != nil { return nil, err } s.mu.RLock() v, ok := s.m[cleaned] s.mu.RUnlock() if !ok { return nil, ErrNotFound } cp := make([]byte, len(v)) copy(cp, v) return cp, nil } func (s *MemoryStore) Exists(_ context.Context, key string) (bool, error) { cleaned, err := cleanKey(key) if err != nil { return false, err } s.mu.RLock() _, ok := s.m[cleaned] s.mu.RUnlock() return ok, nil } func (s *MemoryStore) Delete(_ context.Context, key string) error { cleaned, err := cleanKey(key) if err != nil { return err } s.mu.Lock() delete(s.m, cleaned) s.mu.Unlock() return nil }