Files
pad/internal/attachments/store.go
T
xarmian de4d28d576 feat(attachments): AttachmentStore interface + FSStore (TASK-870) (#287)
* feat(attachments): AttachmentStore interface + FSStore (TASK-870)

Introduces the storage backend abstraction described in DOC-865 and
ships its first concrete implementation. No call sites yet —
TASK-871 (upload API) wires it in.

internal/attachments/store.go
  AttachmentStore interface (Put/Get/Stat/Delete) and ErrNotFound
  sentinel. Put is documented as idempotent — concurrent Puts of the
  same hash converge — and required to verify that the streamed bytes
  actually hash to the supplied value.

internal/attachments/registry.go
  Registry routes "<prefix>:<rest>" keys to the store registered for
  that prefix (Phase 1 = "fs"; Phase 2 will register "s3" alongside).
  Convenience Get/Stat/Delete helpers resolve + forward in one call so
  callers don't have to spell out the two-step pattern everywhere.
  Register panics if the prefix contains ':' since that would make the
  store unreachable.

internal/attachments/fs_store.go
  FSStore writes to <baseDir>/<aa>/<bb>/<full-hash> with the first 4
  hex chars sharding the directory tree two levels deep. Atomic writes:
  stream + hash to a randomized .tmp in the destination dir, fsync,
  then intra-directory rename. The streaming sha256 is verified against
  the supplied hash before the rename, so a mismatch never leaves a
  visible file. Idempotent fast path: if the canonical file already
  exists Put short-circuits (and drains the reader so callers don't get
  a stuck stream). Get returns wrapped ErrNotFound on missing keys;
  Delete on a missing key is a no-op (matches what the orphan GC needs).

Tests cover put/get/stat/delete, hash mismatch, invalid hash format,
idempotency, 16-goroutine concurrent Put of the same hash converging
to one on-disk file with no orphan tmp files, registry routing,
forward-error semantics, and the prefix-with-colon panic.

Parent: PLAN-866.

* fix(attachments): validate hash on every FSStore key + verify on fast path per Codex review (round 1)

Round 1 raised two issues — both real, both fixed.

1. Path traversal in Get/Stat/Delete. extractHash only checked that the
   key began with "fs:" and the suffix was non-empty before passing it
   to pathFor(), which used the suffix as a path component. A key like
   "fs:../../etc/passwd" would escape baseDir for reads/stats/deletes.
   Fix: extractHash now requires the canonical 64-char lowercase-hex
   sha256 form via validHash. Same gate that Put already used; now it
   covers every public method.

2. Idempotent Put fast path skipped hash verification. If the canonical
   target file already existed, Put returned the key without checking
   that the supplied reader's bytes hashed to the supplied hash —
   violating the AttachmentStore.Put contract that implementations MUST
   verify on every call. A buggy upload path could associate the wrong
   bytes with an existing hash and silently succeed. Fix: stream r
   through a hasher when the target exists (no disk I/O), compare
   against the supplied hash, and reject on mismatch.

Also dropped the dead "_short" branch in pathFor — every caller now
goes through validHash.

Tests added:
- TestFSStore_GetStatDeleteRejectBadKeys covers empty/wrong-prefix/empty-
  hash/non-hex/wrong-length/path-traversal/path-separator/uppercase keys
  across all three read methods.
- TestFSStore_PutFastPathStillVerifiesHash confirms the contract holds
  on the fast path: a second Put that lies about the hash is rejected
  with no corruption of the existing file.
2026-04-29 11:44:58 -04:00

60 lines
2.7 KiB
Go

// Package attachments provides the storage backend abstraction for
// attachment blobs (images and files uploaded into items). See DOC-865
// "Attachments — architecture & migration design" for the full design.
//
// Storage is content-addressed: every blob is keyed by sha256(content).
// Identical bytes → identical key → one physical copy. Each AttachmentStore
// implementation maps a hash to a concrete location (filesystem path, S3
// object key, …) and namespaces its keys with a backend prefix
// ("fs:<hash>", "s3:<bucket>/<hash>", …) so a Registry can route a key to
// the right backend by prefix alone. This decouples item content (which
// stores opaque "pad-attachment:<uuid>" references) from the backend the
// blob actually lives in, and enables backend migrations (FS → S3) that
// touch zero item content.
package attachments
import (
"context"
"errors"
"io"
)
// AttachmentStore is the backend abstraction every storage implementation
// must satisfy. Methods are safe for concurrent use.
type AttachmentStore interface {
// Put writes the blob from r into the backend, addressable by hash.
// The implementation MUST verify that the bytes streamed from r hash
// to the supplied hash and return an error if they do not — this is
// the integrity guarantee callers rely on.
//
// Put is idempotent: writing the same hash twice is a no-op on the
// second call. Concurrent Puts of the same hash converge — both
// callers receive the same key and the on-disk content is unchanged.
//
// The returned key is the "<backend>:<...>" form that Registry uses to
// route subsequent Get/Stat/Delete calls back to this store.
//
// mime is informational (some backends, e.g. S3, store it as object
// metadata). The FS backend ignores it; MIME for serving comes from
// the attachments DB row.
Put(ctx context.Context, hash, mime string, r io.Reader) (key string, err error)
// Get returns a ReadCloser positioned at the start of the blob.
// Callers MUST close the returned reader. If key is unknown to this
// store, the returned error wraps ErrNotFound.
Get(ctx context.Context, key string) (io.ReadCloser, error)
// Stat returns the size in bytes of the blob identified by key. If
// key is unknown, the returned error wraps ErrNotFound.
Stat(ctx context.Context, key string) (size int64, err error)
// Delete removes the blob identified by key. Deleting a missing key
// is NOT an error — callers (e.g. the orphan GC) treat it as the
// success case.
Delete(ctx context.Context, key string) error
}
// ErrNotFound is returned (or wrapped) by Get/Stat when the requested key
// is not present in the backend. Callers can compare with errors.Is.
var ErrNotFound = errors.New("attachment not found")