Files
pad/internal
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
..
2026-03-26 01:52:36 +00:00