Skip to content

fix(file-store): make read failures reject instead of surfacing late - #20

Closed
saby1101 wants to merge 1 commit into
mainfrom
fix/provider-correctness
Closed

saby1101 wants to merge 1 commit into
mainfrom
fix/provider-correctness

Conversation

@saby1101

Copy link
Copy Markdown
Member

Batch 1 of the review findings — the correctness fixes. Each has a regression test that fails if reverted. 136 tests, 100% coverage held, MinIO integration green.

GCS getAsStream resolved for missing objects

return gcsfile.createReadStream() did no I/O before returning, so a missing object resolved successfully and the 404 arrived later as a stream 'error'. Every other provider rejects: S3 awaits GetObject, Azure's download() throws, local throws ENOENT.

A caller doing reply.send(await FileStore.getAsStream(k)) therefore sent a 200 with a broken body on GCS, or took the process down on an unhandled 'error'. GCS is the production backend, so this was the one finding with live exposure.

Now checks existence first. Costs an extra round trip on that path; correctness is worth it, and the test asserts createReadStream is not even called for a missing object.

Plugin dispatch accepted Object.prototype names

Configure[opts.type] walked the prototype chain. Demonstrated before the fix:

type="toString"     -> registered, FileStore=UNDEFINED
type="constructor"  -> registered, FileStore=UNDEFINED
type="valueOf"      -> threw: Cannot convert undefined or null to object

A typo booted successfully with nothing decorated, turning into a TypeError on first use rather than a boot failure. Now guarded with hasOwnProperty.call; all three reject with Unknown storage type.

Object.hasOwn would read better but lib is ES2020 — widening it is a separate change, not a side effect of this one.

streamToBuffer could hang forever

Only data / error / end were handled. A stream destroyed without an error argument emits 'close' alone, leaving the promise pending and hanging whichever request awaited getAsBuffer — on Azure, GCS and S3.

Now rejects on premature close, with a settled guard so late events cannot settle twice. The normal path (end then close) is unaffected.

The reviewing agent was honest that it could not trigger this through a real provider — MinIO gives ECONNRESET, Azure destroys with an error — so the trigger needs a stream closed cleanly without one. Unproven in situ, cheap to defend.

Dead branch on local reads, and the tests that enshrined it

getAsBuffer and getAsStream wrapped reads in if (await fs.promises.stat(p)). A fulfilled stat is never falsy, so throw new Error("File not found: ...") was unreachable and callers actually got raw ENOENT.

Two specs reached it only by mocking stat to resolve undefined — a state the real filesystem never produces — and asserted an error the code cannot throw. Anyone reading them would believe missing files throw File not found. They were covering dead code to satisfy the 100% threshold.

getAsBuffer now reads directly (also dropping a redundant stat and its TOCTOU window). getAsStream keeps the stat deliberately: createReadStream is lazy like the GCS reader, so without it a missing file would defer to a stream 'error' — the same bug fixed above. Both specs now assert the real ENOENT.

Verification

lint     clean
build    clean
tests    136 passed, 100% statements/branches/functions/lines
minio    6 passed against a real container

Behaviour confirmed against the built dist/: the three prototype names now throw, and local getAsStream on a missing file rejects with ENOENT.

Not in this batch

Deliberately held for a separate change, since each needs a decision rather than a fix:

  • Missing bucket reads as missing file. A renamed or misconfigured bucket makes every object look absent. A startup HeadBucket probe would catch it, but a pod with object-level IAM and no bucket-level permission would then fail to boot — that trade-off is yours, and a non-fatal warning may be the right shape.
  • Write-path error assertions (batch 2) — six mutations swallowing save/upload rejections survive the suite today.
  • Local traversal, atomic writes, ENOTDIR parity (batch 4) — dev-only backend.

GCS getAsStream returned createReadStream() without any I/O, so a missing object
resolved successfully and failed later as a stream 'error'. A caller doing
reply.send(await getAsStream(k)) sent a 200 with a broken body, or crashed the
process on an unhandled error. Every other provider rejects. GCS is the production
backend. It now checks existence first.

Plugin dispatch read Configure[opts.type] straight off an object literal, so
type:"toString" resolved Object.prototype.toString and registered successfully with
no FileStore decorated — a typo became a silent no-op boot rather than a failure.

streamToBuffer only handled data/error/end. A stream destroyed without an error
emits close alone, leaving the promise pending forever and hanging the request that
awaited getAsBuffer. It now rejects, and a settled guard keeps late events from
settling twice.

Local getAsBuffer/getAsStream wrapped reads in if (await stat(p)), which can never be
falsy since stat throws — so the File not found branch was unreachable and callers
got raw ENOENT. Two specs reached it only by mocking stat to resolve undefined, a
state the real filesystem never produces, and asserted an error the code cannot throw.
getAsBuffer now reads directly; getAsStream keeps the stat so a missing file rejects
rather than deferring to a stream error, matching the GCS fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@saby1101

Copy link
Copy Markdown
Member Author

Superseded by #23, which carries these commits unchanged. Splitting this into separate reviews added overhead without adding safety.

@saby1101 saby1101 closed this Aug 14, 2026
@saby1101
saby1101 deleted the fix/provider-correctness branch August 14, 2026 12:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant