feat(datastores): add credential preflight and timeout guards #72
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/credential-preflight-timeouts"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
preflightCredentials()method (3s timeout viaPromise.race) called once before the firstpullChanged/pushChangedoperation. Disables IMDS credential lookup when not in a container environment (AWS_EC2_METADATA_DISABLED) to avoid the 1s+ hang on developer laptops. Credential errors flow through existingclassifyAwsCredentialError/formatAwsCredentialHint.AbortSignal.timeout(2s)to all three token-exchange fetch calls (tokenFromServiceAccount,tokenFromUserCredentials,tokenFromMetadataServer) — previously unbounded. AddspreflightCredentials()method (5s timeout) onGcsClientcalled once before first sync op. Wraps token timeout errors inheaders()asGcsOperationError.S3CacheSyncService,GcsCacheSyncService) callensurePreflight()once at the start ofpullChanged/pushChanged.ADV-1 (timeout alignment): 2s per-call token timeout, 5s chain timeout — no conflict.
ADV-2 (IMDS): Used
AWS_EC2_METADATA_DISABLEDenv var (process-wide but acceptable — S3 datastore is the sole AWS SDK consumer) rather than adding@aws-sdk/credential-providersdependency which risks bundler/Deno npm compatibility issues.Test plan
preflightCredentialssucceeds on accessible bucketpreflightCredentialstimes out on stalled server (within 3s budget)preflightCredentialspropagates 403 with auth hintpreflightCredentialssucceeds with fast tokenFnpreflightCredentialsno-ops in emulator mode (no tokenFn)preflightCredentialstimes out on slow credential source (within 5s budget)preflightCredentialspropagates token refresh errors with session-expired hintsdeno check,deno lint,deno fmt,deno install --frozenclean on both extensions🤖 Generated with Claude Code
Code Review
Result: PASS — No blocking issues found.
Summary
This PR adds credential preflight verification (
preflightCredentials()) to bothGcsClientandS3Client, and wires it intoGcsCacheSyncServiceandS3CacheSyncServicevia a one-shotensurePreflight()guard. The intent is to surface credential failures fast (before the first real data operation) with a hard timeout.CLAUDE.md Compliance
anytypes: Confirmed. All new code uses explicit types.TOKEN_FETCH_TIMEOUT_MS,ADC_CHAIN_TIMEOUT_MS,PREFLIGHT_TIMEOUT_MSare all named exports.deno.lockcommitted:datastore/gcs/deno.lockis updated and committed.model/files: Confirmed. Nomodel/files touched.Testing Rules
Deno.serve({ port: 0 })) or in-memory mock clients.sanitizeResources: falsewith comments: The timeout test ingcs_client_test.tsusessanitizeOps: falsewith an explanatory comment. Correct.AWS_EC2_METADATA_DISABLEDis saved/restored in afinallyblock ins3_client_test.ts. Correct.preflightCredentials()is tested in bothgcs_client_test.tsands3_client_test.ts. Mock clients in both*_cache_sync_test.tsfiles are updated to includepreflightCredentials().Security
Deno.env.setscope: TheAWS_EC2_METADATA_DISABLEDmutation inS3Client's constructor is a process-wide side effect. It is guarded by a check for existing values and container-credential env vars, and the rationale is documented in a comment. No security concern, but see suggestions below.Correctness
Promise.raceagainst asetTimeout-based rejection withclearTimeoutin afinallyblock. Correct — no timer leaks on the fast path.preflightDoneflag ensures preflight runs at most once per service instance regardless of concurrent callers (since JS is single-threaded, no race on the flag itself).probe.catch(() => {})in GCS preflight: Prevents unhandled-rejection noise if the timeout fires first and the probe later rejects. Correct.headers(): Wraps these errors intoGcsOperationErrorwith context. Correct.Suggestions (non-blocking)
GCS preflight ignores the caller's AbortSignal (
gcs_cache_sync.ts:ensurePreflight,gcs_client.ts:preflightCredentials):GcsCacheSyncService.ensurePreflight()accepts no signal andGcsClient.preflightCredentials()is also signal-less. If a caller abortspullChanged/pushChangedwithin the 5 sADC_CHAIN_TIMEOUT_MSwindow, the GCS preflight continues running. S3's counterpart forwards the signal. This is safe —ADC_CHAIN_TIMEOUT_MSprovides a hard upper bound — but the asymmetry with S3 is worth noting for future maintainers.Process-wide env var mutation in
S3Clientconstructor (s3_client.ts):Deno.env.set("AWS_EC2_METADATA_DISABLED", "true")affects all code in the process that reads this var, including any futureS3Clientinstances. The existing comment explains the intent (prevent IMDS hang off-cloud). Consider whether this belongs in a one-time module-level init rather than every constructor invocation, though the idempotent guard (if (!Deno.env.get(...))) means repeated construction is safe in practice.GCS
formatBatchFailurepreposition ternary is slightly verbose (gcs_cache_sync.ts):op === "pull" ? "from" : op === "delete" ? "from" : "to"can be simplified toop === "push" ? "to" : "from"to match the S3 sibling's style. Functionally equivalent.Adversarial Code Review — GCS & S3 Datastore Sync
Summary
Reviewed 8 changed files across the GCS and S3 datastore cache sync implementations. The GCS scoped-walk error handling has two HIGH-severity issues that can cause silent remote data deletion under non-exotic conditions (transient filesystem errors, lazy hydration). The S3 implementation handles both cases correctly, suggesting the GCS code was ported but missed guards added later.
CRITICAL / HIGH
H-1 · GCS scoped walk bare catch schedules remote deletion on any filesystem error
datastore/gcs/extensions/datastores/_lib/gcs_cache_sync.ts:1197What: The scoped walk's
catchblock at line 1197 is a barecatchwith no error type discrimination. WhenDeno.stat()throws for any reason — permission denied, I/O timeout, NFS stale handle, disk error — the code falls through to line 1202 and schedules all matching index entries for remote deletion.S3 comparison: The equivalent S3 code at
s3_cache_sync.ts:1330-1336correctly discriminates:Breaking scenario:
pushChanged.Deno.stat()throws a genericError(notDeno.errors.NotFound).toDelete.pushChangeddeletes those entries from the remote GCS bucket.Suggested fix:
H-2 · GCS scoped walk catch block missing lazyPullActive guard — deletes un-hydrated files
datastore/gcs/extensions/datastores/_lib/gcs_cache_sync.ts:1202What: When a dirty path is absent on disk and the catch block fires, GCS unconditionally schedules matching index entries for remote deletion (line 1202:
if (this.index)). It does not checkthis.lazyPullActive. During lazy hydration, raw data files are intentionally not pulled to disk — they are absent by design, not because the user deleted them. The comment on line 1199-1201 explicitly acknowledges this tradeoff ("wins over the lazy hydration guard") but the S3 implementation chose the safer default.S3 comparison: At
s3_cache_sync.ts:1341:S3 blanket-skips remote deletion scheduling when
lazyPullActiveis true, which is the safe default: you cannot distinguish "absent because un-hydrated" from "absent because deleted" without additional state.Breaking scenario:
markDirty("data/models/raw")on the GCS sync service.pushChangedenters the scoped walk.Deno.statthrowsNotFoundfor the un-hydrated path.data/models/are scheduled for deletion.Suggested fix: Add the
lazyPullActiveguard:MEDIUM
M-1 · GCS scoped walk skips within-directory deletion detection
datastore/gcs/extensions/datastores/_lib/gcs_cache_sync.ts:1186-1196What: When a dirty path resolves to a directory, GCS walks it and pushes changed files (lines 1186-1195), but does NOT collect
localFilesInDiror compare against the index to detect files that were deleted within that directory.S3 comparison: At
s3_cache_sync.ts:1305-1327, S3 collectslocalFilesInDirand then scans index entries under the directory prefix, scheduling deletion for any that are not found locally (with alazyPullActiveguard).Impact: If a user deletes a single file inside a dirty directory, the GCS scoped walk will push the remaining files but silently fail to propagate the deletion to GCS. The deletion would eventually be caught on the next bulk-invalidated push, but the intermediate state is inconsistent. This is a correctness gap, not data loss — it errs on the side of keeping extra data.
Suggested fix: Add
localFilesInDirtracking and index comparison, mirroring the S3 pattern.M-2 · GCS bulk-walk deletion gate uses bulkInvalidated instead of dirtyPathsOverflowed
datastore/gcs/extensions/datastores/_lib/gcs_cache_sync.ts:1232What: The GCS bulk walk gates orphan deletion on
this.bulkInvalidated(line 1232).bulkInvalidatedis set by three triggers: (1) dirty path cap overflow, (2) path escaping cache dir, (3) no-relPathmarkDirty()call (legacy/pushFile path). The S3 version usesthis.dirtyPathsOverflowed(line 1375), which is only set by trigger (1).Behavioral difference: A
markDirty()call withoutrelPath(e.g., frompushFileor a legacy caller) setsbulkInvalidated = truewithoutdirtyPathsOverflowed = true. In S3, this means the bulk walk will not delete orphans — the no-relPath call is treated as "something changed" not "deleted files should be cleaned up." In GCS, the same call enables orphan deletion.GCS also has no
dirtyPathsOverflowedfield at all — it was never ported from S3.Impact: A sequence of
pushFilethenmarkDirty()thenpushChangedon GCS could delete remote files that are merely un-fetched locally, if the local cache is a subset of the remote. The risk is amplified for reader-side repos that only have partial content locally.Suggested fix: Add
dirtyPathsOverflowedstate to GCS (mirroring S3'sDatastoreSyncStateV2field) and use it instead ofbulkInvalidatedfor the bulk walk deletion gate. Also add the!this.lazyPullActiveguard that S3 has.LOW
L-1 · Module-level GCS token cache shared across all client instances
datastore/gcs/extensions/datastores/_lib/gcs_client.ts:243What:
cachedTokenat line 243 is a module-levelletvariable. AllGcsClientinstances in the same Deno isolate share this single cache slot. If twoGcsClientinstances are constructed with different credential sources (e.g., different service account key paths viaGOOGLE_APPLICATION_CREDENTIALS), the second instance'sgetToken()call may reuse a token minted for the first instance's credentials, leading to 403 errors.In practice: This is low severity because typical deployment has one credential per process. It becomes a problem only in multi-tenant scenarios or tests that construct clients with different credentials in the same process.
Suggested fix: Move
cachedTokento an instance field onGcsClient.Informational
I-1 · GCS markDirty does not set dirtyPathsOverflowed on cap overflow
Note that the GCS
markDirtyat line 557-560 setsbulkInvalidated = trueon dirty path cap overflow but has nodirtyPathsOverflowedflag. This is the root cause of M-2 — the two concepts (bulk invalidation and overflow-triggered deletion) are conflated in GCS but correctly separated in S3.I-2 · Both implementations use Deno.writeFile (non-atomic) in pullFile
Both GCS (
gcs_cache_sync.ts) and S3 (s3_cache_sync.ts)pullFilemethods useDeno.writeFileto write downloaded content to disk. This is not atomic — a crash mid-write leaves a partial file. Both implementations self-heal on next sync via fingerprint mismatch, so the window of inconsistency is small. Mentioning for completeness; not actionable unless crash-consistency requirements tighten.Verdict
FAIL — 2 HIGH findings (H-1, H-2) require changes before merge. The GCS scoped walk error handling diverges from the S3 implementation in ways that can cause silent data deletion under recoverable error conditions and during lazy hydration.
Code Review
Blocking Issues
1. GCS pushChanged scoped walk: isDirectory branch missing per-file deletion detection (gcs_cache_sync.ts)
When the scoped walk processes a dirty path that resolves to a still-existing directory, the GCS implementation does NOT detect files that have been deleted from within that directory. The S3 sibling (s3_cache_sync.ts) correctly handles this with a two-phase approach:
Phase 1 (same as GCS): walk the directory, collect files to push via fileNeedsPush.
Phase 2 (MISSING from GCS): build a set of all local files found during the walk, then scan this.index.entries for any entry under the dirtyPath prefix that is NOT in the local set -- and add those orphans to toDelete.
Impact: If markDirty is called with a relPath pointing to a parent directory, and then one file inside that directory is deleted while the directory and other files survive, the GCS implementation silently leaves the orphaned object in GCS. The S3 implementation correctly detects and deletes it.
The existing del1 test ("pushChanged: scoped walk deletes absent dirty path from GCS and index") only covers the case where the ENTIRE marked directory is removed -- handled by the catch (err instanceof Deno.errors.NotFound) path. There is no test for partial deletion within a surviving directory, so this regression has no coverage.
Fix needed: Port the localFilesInDir set pattern from s3_cache_sync.ts isDirectory branch into gcs_cache_sync.ts. After the walk loop, check this.index.entries for keys starting with the dirtyPath prefix that are absent from localFilesInDir (guarded by lazyPullActive), and push those to toDelete. Also add a del5 test: mark a directory dirty, delete one file within it while leaving others, assert the deleted file is removed from GCS and from the index.
Suggestions
1. Document the semantic divergence between bulkInvalidated (GCS) and dirtyPathsOverflowed (S3) (gcs_cache_sync.ts bulk walk, s3_cache_sync.ts bulk walk)
S3's bulk walk deletes orphaned entries only when dirtyPathsOverflowed is true (path count exceeded DIRTY_PATHS_CAP). Its code comments explicitly state that a no-relPath markDirty() is a modification signal, not a deletion signal -- S3 does NOT delete orphans in that case. GCS uses bulkInvalidated as the gate, which is set by BOTH no-relPath markDirty() calls AND path overflow. This means calling markDirty() with no relPath triggers orphan deletion in GCS but not in S3. The GCS del2 test confirms this is intentional, but a short comment in the bulk-walk deletion block documenting the divergence from S3 semantics would help future maintainers.
2. Misleading comment in DEF-2 integration test (gcs_cache_sync_test.ts)
A comment describes "monkey-patching the module-level default via a dedicated assertion path" but the code simply calls retryWithBackoff directly with a custom config argument -- no patching occurs. The comment should be removed or corrected.
3. Simplify formatBatchFailure preposition ternary (gcs_cache_sync.ts)
The three-way ternary (op === "pull" ? "from" : op === "delete" ? "from" : "to") can be replaced with a clearer two-way form (op === "push" ? "to" : "from") to match the S3 sibling's style.
Adversarial Review
High
H-1: GCS scoped walk treats ALL stat errors as deletion intent (data loss risk)
File:
datastore/gcs/extensions/datastores/_lib/gcs_cache_sync.ts:1197-1209Dimension: Logic and Correctness, Data Integrity
Severity: HIGH
The scoped dirty-path walk wraps
Deno.stat()and the subsequent directory walk in a singletryblock with a barecatch. Any error —Deno.errors.PermissionDenied,Deno.errors.Interrupted, NFS timeout, or an unexpected runtime error fromwalk()— is silently interpreted as “file absent on disk” and schedules remote object deletion.GCS (lines 1197-1209):
S3 correctly distinguishes (lines 1330-1336):
Impact: A transient permission error or I/O failure during a scoped push could silently delete objects from GCS, causing irreversible data loss. This is a correctness divergence from the S3 backend that changes the safety contract of
pushChanged.Recommendation: Guard the catch block identically to S3: only enter the deletion path for
Deno.errors.NotFound, andcontinuefor all other errors.H-2: GCS scoped walk missing lazyPullActive guard on deletion path
File:
datastore/gcs/extensions/datastores/_lib/gcs_cache_sync.ts:1202Dimension: Data Integrity, Logic and Correctness
Severity: HIGH
In the bare-catch deletion branch, GCS checks only
if (this.index)before scheduling remote deletions. S3 checksif (!this.lazyPullActive && this.index)(line 1341).When lazy hydration is active, files that have not been pulled yet are absent on disk by design — they are un-hydrated, not deleted. Without the
lazyPullActiveguard, GCS will interpret these absent-but-un-hydrated files as deleted and remove them from the remote store.Impact: A lazy-pull repo that runs
pushChangedwith a single dirty path will delete every un-hydrated file under that prefix from GCS.Recommendation: Change line 1202 to
if (!this.lazyPullActive && this.index), mirroring S3 line 1341.Medium
M-1: GCS scoped directory walk does not detect within-directory deletions
File:
datastore/gcs/extensions/datastores/_lib/gcs_cache_sync.ts:1186-1196Dimension: Logic and Correctness
Severity: MEDIUM
When a dirty path resolves to a directory that still exists, GCS walks it for files to push but never checks whether any indexed files under that directory were deleted locally. S3 (lines 1304-1328) builds a
localFilesInDirset and cross-references the index to find orphans.GCS has no equivalent logic. If a user deletes a file inside a dirty directory (without deleting the directory itself), GCS will never schedule it for remote deletion.
Impact: Deleted files persist as ghost objects in GCS and reappear on the next pull, making file deletion within directories ineffective.
Recommendation: Port the
localFilesInDirpattern from S3 into the GCSisDirectorybranch.M-2: GCS
ensurePreflight()does not accept or forward AbortSignalFile:
datastore/gcs/extensions/datastores/_lib/gcs_cache_sync.ts:454-457Dimension: API Contract, Cancellation
Severity: MEDIUM
GCS
ensurePreflight()(line 454) takes no parameters and callsthis.gcs.preflightCredentials()without a signal. S3 (line 497) accepts an optionalAbortSignaland forwards it:Impact: When a caller cancels a GCS pull/push via
AbortSignal, the preflight credential check runs to completion (or its internal timeout) instead of aborting immediately.Recommendation: Add
signal?: AbortSignaltoensurePreflightand forward it topreflightCredentials. Update both call sites (lines 949 and 1151) to passsignal.M-3: Divergent deletion semantics between
bulkInvalidated(GCS) anddirtyPathsOverflowed(S3)File:
datastore/gcs/extensions/datastores/_lib/gcs_cache_sync.ts:1232vsdatastore/s3/extensions/datastores/_lib/s3_cache_sync.ts:1375Dimension: Logic and Correctness
Severity: MEDIUM
In the full-walk branch, GCS guards deletion on
this.bulkInvalidated(line 1232) while S3 guards onthis.dirtyPathsOverflowed(line 1375). The GCS sync state schema (DatastoreSyncStateV2) does not includedirtyPathsOverflowedat all.These are not the same condition.
bulkInvalidatedis set when the entire cache is marked dirty;dirtyPathsOverflowedis set when the per-path tracking set exceeds its cap and falls back to full walk.Impact: Depending on how the core sets these flags, one backend may delete remote files in scenarios where the other does not, leading to subtle cross-backend behavior differences.
Recommendation: Align both backends on the same guard condition. If
dirtyPathsOverflowedis the correct semantic, add it to the GCS sync state and use it instead ofbulkInvalidated.Low
L-1:
isInternalCacheFilemay not match namespace-prefixed internal filesFile:
datastore/gcs/extensions/datastores/_lib/gcs_cache_sync.ts:103-114Dimension: Defensive Coding
Severity: LOW
isInternalCacheFilechecks for exact filenames (.datastore-index.json,.push-queue.json,.datastore.lock,_index, etc.) and basename patterns (.lock,_catalog.db). However, when a namespace is bound viabindNamespace, the cache path itself changes but the relative paths passed toisInternalCacheFilemay still include a namespace prefix. If a namespace prefix ever appears in the relative path, the exact-match checks would fail and internal files could be uploaded to the remote store.Impact: Low risk currently — the namespace binding appears to change
this.cachePathitself, so relative paths should not carry the prefix. But the function is brittle and would silently fail if the path-construction logic ever changes.Recommendation: Add a defensive check that strips any leading namespace prefix before matching, or add a test that verifies internal files are never uploaded when a namespace is bound.
Verdict
BLOCKING — do not merge.
Required before merge:
Deno.errors.NotFound!this.lazyPullActiveguard at line 1202localFilesInDircross-reference in theisDirectorybranchsignalparameter toensurePreflightand forward itCode Review
Result: APPROVED — no blocking issues found.
CLAUDE.md Compliance
anytypes in hand-written code. Test mocks useas unknown as GcsClient & {...}for controlled upcasting in test infrastructure, which is acceptable.@aws-sdk/client-s3@3.1046.0is pinned to an exact version.deno.lockis present and committed for the GCS extension.model/were modified.Testing Rules
Deno.serve({ port: 0 })(GCS/S3 client tests) or in-memory mock clients (createMockGcsClient) — no live cloud services.AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEYinfinallyblocks. GCS tests use an emulator-mode ADC bypass that requires no env var teardown.sanitizeResources: falseis set on all tests that instantiate a realGcsClientorS3Client, each annotated with a comment explaining the TCP connection pool reason.Security
bodyPreviewcapture ins3_client.tsis capped at 256 bytes — intentional, documented.assertSafePath(cachePath, relativePath)usingpath.normalize+startsWithfor local cache writes.fetchForeignContentrejects paths with..segments or leading slashes before any local I/O.cachedToken) is a module-level singleton; tests callclearTokenCache()infinallyto prevent state leakage between test cases.Correctness
isRetryableErroringcs_cache_sync.tsadds explicit non-retryable guards forNotFoundErrorandPreconditionFailedErrornot present in the S3 version — correct, since these indicate caller-side logic errors rather than transient infrastructure faults.preflightCredentialsraces the token probe againstADC_CHAIN_TIMEOUT_MS(5 s) with abort signal propagation — correctly cleans up on timeout.Suggestions (non-blocking)
1. Undocumented behavioral divergence:
markDirty()deletion semantics between GCS and S3In
gcs_cache_sync.ts, a baremarkDirty()call (norelPath) setsbulkInvalidated = true, which in the full-walk push path triggers orphan deletion of local files not present in the remote index. Ins3_cache_sync.ts, orphan deletion in the full walk is only triggered bydirtyPathsOverflowed(the 200-entry cap being exceeded via scopedmarkDirty({ relPath })calls); a baremarkDirty()is explicitly documented as "a modification signal, not a deletion signal."This divergence is intentional — it is explicitly validated by the test
"pushChanged: bulk walk deletes orphaned index entries after markDirty()"ingcs_cache_sync_test.ts. However, the publicDatastoreSyncServiceinterface carries no documentation of this per-provider difference, which creates a footgun for callers who assume cross-provider parity. Consider adding a JSDoc comment onmarkDirty()inGcsCacheSyncServicenoting that bulk invalidation also schedules orphan deletion.2. Minor verbosity in
formatBatchFailurepreposition logic (gcs_cache_sync.ts)Functionally identical. Low priority, but worth aligning for consistency between providers.
Adversarial Review
Critical / High
datastore/s3/extensions/datastores/_lib/s3_client.ts:342-349AWS_EC2_METADATA_DISABLED=truewhenAWS_CONTAINER_CREDENTIALS_RELATIVE_URIandAWS_CONTAINER_CREDENTIALS_FULL_URIare absent. This correctly preserves ECS/EKS container credential sources but silently disables IMDS (Instance Metadata Service) on bare EC2 instances, where the instance profile attached via IAM role is the sole credential source.AWS_PROFILE,AWS_ACCESS_KEY_ID, or container credential env vars). The S3Client constructor disables IMDS.preflightCredentialsthen fails withCredentialsProviderError, whichclassifyAwsCredentialErrormaps to"session-expired", producing the hint: "Datastore session expired: your AWS profile's SSO session is no longer valid. Run 'aws sso login' to refresh, then retry." The user's SSO session is fine — the code disabled their actual credential source and then blamed the wrong thing.SWAMP_DISABLE_IMDS=true) so the optimization is opt-in. Alternatively, accept the 1s IMDS timeout on local dev as the cost of not breaking EC2 deployments — the newPREFLIGHT_TIMEOUT_MS(3s) already provides a fast-failure path, and the IMDS 1s timeout fits within that budget.Medium
preflightCredentials: abort signal listener may silently miss an abort fired between the early check andaddEventListenerdatastore/gcs/extensions/datastores/_lib/gcs_client.ts:731-785signal?.abortedand throws early. Line 771 registers anaddEventListener("abort", ...)listener. If the signal aborts in the microtask gap between these two points, the abort event has already dispatched — the listener never fires (per EventTarget spec,addEventListenerdoes not replay past events). The abort racer promise never settles, and thePromise.racemust wait for theprobeor the 5s timeout to settle it.preflightCredentialsbegins. The early check passed (at T=0, not yet aborted). The listener was attached after the event fired. The token fetch continues. The race settles at T=5s (timeout) instead of T=0.5s (abort). The user waits 4.5 extra seconds.signal.abortedand manually reject if true. Or replace the listener-based approach withAbortSignal.any([signal, ...])composed into the race structure.Low
datastore/s3/extensions/datastores/_lib/s3_client.ts:548-581anddatastore/gcs/extensions/datastores/_lib/gcs_client.ts:729-792preflightCredentialsusePromise.racewith a timeout. When the timeout wins, the losingprobepromise continues running in the background. For S3, theheadBucketSDK call continues until the SDK's ownrequestTimeout(default 30s). For GCS, thegetAccessTokenchain continues until per-call timeouts resolve. Theprobe.catch(() => {})swallows the eventual rejection.Verdict
FAIL — the S3 IMDS disable (finding #1) silently breaks a common AWS deployment pattern (EC2 instance profiles) and produces a misleading error message that points users at the wrong remediation. The GCS preflight, scoped-walk deletion fix,
lazyPullActiveguard, and error discrimination improvements are all well-engineered. The test coverage is thorough. Fix finding #1 and this is ready to merge.269f2e4aabde74f50205Code Review
Blocking Issues
None identified.
Suggestions
GCS
bulkInvalidatedvs S3dirtyPathsOverflowedfor orphan scanning (gcs_cache_sync.ts)In the GCS implementation, orphan deletion in the bulk walk path is gated on
this.bulkInvalidated. The S3 equivalent gates onthis.dirtyPathsOverflowed— a distinct condition that fires only when the dirty-path cap overflows (not on every bulk invalidation). If a GCS pull is triggered by an explicit bulk invalidation (e.g. first-time pull, explicit invalidation signal) but the dirty-path cap was not exceeded, the GCS path will still scan for orphans while S3 would skip that scan. This asymmetry is likely intentional (GCS design treats all bulk invalidations as orphan-scan-eligible), but it would be worth a brief comment at the scan site explaining whybulkInvalidatedis the right sentinel here rather than a cap-overflow flag. As written, a future reader may assume this is a bug.Module-level
cachedTokensingleton ingcs_client.tsThe
cachedTokenvariable is module-level state shared across allGcsClientinstances in the process. This is reasonable for ADC (which is process-scoped), andclearTokenCache()is exported for test hygiene. However, if swamp ever hosts multiple GCS datastores configured with different service-account credential files (GOOGLE_APPLICATION_CREDENTIALS), they will contend on the same cache and the last writer wins. A comment noting this design assumption ("ADC credentials are process-global; multi-account configurations are not supported") would prevent a future multi-tenant footgun.preflightCredentialstimer leak on success path (gcs_client.ts)Inside
preflightCredentials, thePromise.raceapproach spawns asetTimeout-backed rejection that continues to live after the credential fetch succeeds. The timer is not cleared on the success path, which will cause a sanitizer warning in tests unlesssanitizeOps: falseis set. The existing tests may already suppress this withsanitizeResources: false(which does not cover op sanitization), but it's worth verifying or restructuring toclearTimeouton success (pattern: capture the timer id, wrap in try/finally). See the analogous fix applied to the GCS preflight timeout test (fix: suppress leaked-timer sanitizer).S3
preflightDonenot reset on credential rotation (s3_cache_sync.ts)ensurePreflightsetspreflightDone = truepermanently. If the underlying S3 client's credentials are rotated (e.g. the vault re-issues credentials mid-session), the preflight guard will not re-run. This matches the GCS behaviour and is presumably acceptable — noting it here since the GCS side has the same characteristic. A comment "preflight is one-shot per service instance; credential rotation requires a new instance" would make the intent explicit.Adversarial Code Review — Credential Preflight & Timeout Guards
Scope: 9 files, +607/-25 lines across GCS and S3 datastore extensions.
Commits reviewed:
4a89e42d1..de74f5020(5 commits)Summary
This PR adds credential preflight checks (fail-fast before first I/O), timeout guards on token acquisition (GCS ADC chain), IMDS disabling for non-container AWS environments, improved error discrimination in the GCS scoped-walk catch block, per-file deletion detection inside surviving directories, and a lazy-hydration guard to prevent deleting un-pulled files. The changes are defensive hardening with thorough test coverage.
Verdict: PASS — no CRITICAL or HIGH findings.
Findings
MEDIUM-1: S3
preflightCredentialsomits pre-abort check (asymmetry with GCS)File:
datastore/s3/extensions/datastores/_lib/s3_client.ts:548The GCS implementation checks
signal?.abortedat the top ofpreflightCredentialsand throws immediately with a descriptiveGcsOperationError(lines 731-739 ingcs_client.ts). The S3 implementation does not — it forwardssignalintoheadBucket(), which forwards it through the SDK'ssend(), which will eventually throw anAbortError. The behavior is functionally correct (an already-aborted signal will cause the SDK call to fail), but the error path differs: the GCS path produces a descriptive"GCS credential preflight aborted"message, while S3 produces a generic SDK abort error that gets wrapped throughwrapError. This is a consistency gap, not a bug.Severity: MEDIUM — No data loss or security risk. The signal is honored in both paths. But the diagnostic quality on pre-aborted calls differs, which could confuse debugging.
Recommendation: Add a 3-line guard at the top of
S3Client.preflightCredentials:MEDIUM-2:
AWS_EC2_METADATA_DISABLEDis a process-wide side effect in a constructorFile:
datastore/s3/extensions/datastores/_lib/s3_client.ts:342-348The constructor sets
AWS_EC2_METADATA_DISABLED=truewhen no container credential env vars are detected. The inline comment correctly acknowledges this is process-wide and asserts "swamp's datastore is the sole AWS SDK consumer in the process." This is a reasonable assertion today.Severity: MEDIUM — The env var is set unconditionally (not restored on teardown, not scoped to the client instance). If a second AWS SDK consumer is ever introduced in the same process that legitimately needs IMDS (e.g., a vault provider running on EC2), this will silently break it. The "only if not already set" guard (
!Deno.env.get("AWS_EC2_METADATA_DISABLED")) is a good defensive check but doesn't address the multi-consumer scenario.Recommendation: Acceptable as-is given the documented constraint. If multi-consumer becomes realistic, move to SDK-level configuration (
credentials: fromNodeProviderChain({ ec2InstanceMetadata: false })) instead of environment mutation.LOW-1: GCS
preflightCredentialstimer creates aclearTimeout-able timer without signal cleanupFile:
datastore/gcs/extensions/datastores/_lib/gcs_client.ts:745-791When
signalis provided, the signal's abort listener (line 771) is registered with{ once: true }— correct. However, ifprobewins thePromise.race, the abort listener remains registered on the signal until the signal itself is GC'd or aborted. This is harmless for short-lived signals but could accumulate listeners if the same long-lived signal is passed to many sequential preflight calls.Severity: LOW — Preflight is called once per sync service lifecycle (
preflightDoneflag gates it), so accumulation is not realistic.LOW-2: S3
preflightCredentialslacks a signal-forwarded abort racer (asymmetry with GCS)File:
datastore/s3/extensions/datastores/_lib/s3_client.ts:548-581The GCS
preflightCredentialsconstructs a 3-way race:[probe, timeout, signalAbort]. The S3 version races only[probe, timeout], relying on signal forwarding throughheadBucket → run → send. This is correct because the SDK does honor the signal. But ifheadBuckethangs at the TCP layer (past the SDK'srequestTimeout), the GCS version would abort via the signal racer while S3 would wait for thePREFLIGHT_TIMEOUT_MStimer. SincePREFLIGHT_TIMEOUT_MS(3s) is shorter thandefaultRequestTimeoutMs(30s), this matters — the signal path is the faster escape.Severity: LOW — The PREFLIGHT_TIMEOUT_MS timer provides a hard upper bound of 3s regardless, so worst-case responsiveness is bounded. The difference only manifests if the caller aborts between "now" and "3s from now", which is an edge case.
LOW-3: No test for pre-aborted signal in S3
preflightCredentialsFile:
datastore/s3/extensions/datastores/_lib/s3_client_test.tsGCS tests include a test for calling
preflightCredentialswith an already-aborted signal. The S3 test suite does not have an equivalent. This is consistent with the code asymmetry noted in MEDIUM-1 but reduces coverage of the signal-forwarding path.Severity: LOW — Covered implicitly by the SDK's own abort handling, but an explicit test would close the gap.
Positive Observations
probe.catch(() => {})pattern (bothgcs_client.ts:743,s3_client.ts:550): Correctly prevents unhandled promise rejection when the timeout or signal wins the race and the probe rejects later. This is a common source of bugs in Promise.race patterns — handled properly here.Error discrimination in scoped walk (
gcs_cache_sync.ts, commitea1915f89): The old barecatchtreated any stat error as "file absent → schedule deletion." Permission errors, NFS timeouts, or I/O failures would have caused silent remote deletions of files that still exist. The fix correctly narrows toDeno.errors.NotFoundonly and skips other errors for retry. This is a meaningful data-safety improvement.Lazy hydration guard (
gcs_cache_sync.ts, commitea1915f89):if (!this.lazyPullActive && this.index)prevents the scoped walk from scheduling deletions for files that are absent because they haven't been hydrated yet (lazy pull). Without this, a push after a lazy pull would delete un-hydrated remote files. Matches the S3 implementation.Per-file deletion detection (
gcs_cache_sync.ts, commitde74f5020): ThelocalFilesInDirset correctly detects files that existed in the index under a dirty directory but are absent on disk. The previous code only detected whole-directory deletions — individual file deletions inside surviving directories were missed. Good use of the index-scanning pattern already established for the non-directory catch path.composeTokenSignalin GCS (gcs_client.ts): Cleanly separates per-call timeout (2sTOKEN_FETCH_TIMEOUT_MS) from chain-level timeout (5sADC_CHAIN_TIMEOUT_MS) and external abort signal. UsesAbortSignal.any()for composition — correct and idiomatic.Token error wrapping in
headers()(gcs_client.ts): Converts rawTimeoutError/AbortErrorfrom the token path intoGcsOperationErrorso the retry classifier in the sync layer can handle them uniformly. Previously these would have propagated as untyped errors.Test coverage is thorough: preflight timeout, preflight success, pre-aborted signal (GCS), mock client conformance updates, scoped-walk deletion detection, lazy-pull guard, and sanitizer annotations are all present.