fix(gcs-datastore): add read-before-write conflict detection in registerNamespace (#567) #37
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/567-gcs-namespace-conflict-detection"
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
registerNamespaceso it does not rely solely onifGenerationMatch=0precondition enforcementgcs_lock.ts(setIntervalreturnsTimeout, notnumber)2026.06.05.1Root cause
registerNamespaceusedputObjectConditional(ifGenerationMatch=0) as its sole mechanism for detecting existing namespace manifests. When a GCS-compatible backend doesn't enforce this precondition (e.g., some versions of fake-gcs-server), the conditional write silently succeeds,if (created !== null) returnshort-circuits, and the repoId comparison is never reached. The manifest is overwritten with no conflict error.Fix
Read the existing manifest before attempting to write. If it exists and belongs to a different repo, throw immediately. Keep
putObjectConditionalas a secondary atomicity guard for concurrent-write races, with a re-read fallback if the conditional write fails.What changed
datastore/gcs/extensions/datastores/gcs.tsNotFoundError; rewriteregisterNamespaceto read-before-write withputObjectConditionalas secondary atomicity guarddatastore/gcs/extensions/datastores/gcs_test.tsdatastore/gcs/extensions/datastores/_lib/gcs_lock.tsheartbeatIdtype fromnumbertoReturnType<typeof setInterval>datastore/gcs/manifest.yaml2026.06.03.2→2026.06.05.1Test plan
deno checkpasses (including previously brokengcs_lock.ts)deno lint,deno fmt --check,deno install --frozenpassCloses #567
Adversarial Review
Medium
TOCTOU window on non-compliant backends still allows concurrent silent clobber —
gcs.ts:168-201The read-before-write check closes the sequential gap on backends that ignore
ifGenerationMatch=0(e.g. fake-gcs-server), but the concurrent gap remains. Two processes racingregisterNamespacewith differentrepoIdvalues on such a backend can both observeNotFoundErrorin the read step (line 169), then both proceed toputObjectConditional(line 200), which succeeds for both (backend ignores the precondition), and both return at line 201 without detecting the conflict — last writer wins silently.Example:
getObject("infra/.namespace.json")→ 404 → falls throughgetObject("infra/.namespace.json")→ 404 → falls throughputObjectConditional→ succeeds (writes repo-aaa) → returnsputObjectConditional→ succeeds (backend ignores precondition, clobbers with repo-bbb) → returnsThis is pre-existing (the code before this PR had the same concurrent-race behavior on non-compliant backends), and the sequential case is the one this PR targets. Flagging as MEDIUM because the new read-before-write check may give a false sense of full protection — a comment noting the concurrent limitation would prevent someone from removing the
putObjectConditionalfallback later under the assumption the read-before-write alone is sufficient.Suggested fix: Add a brief comment near line 193 noting that the read-before-write covers the sequential case, while
putObjectConditionalremains the primary concurrency guard on compliant backends.Low
Unconditional overwrite in the race-recovery path —
gcs.ts:216After
putObjectConditionalreturnsnull(object exists) and the ownership re-check confirms samerepoId, the code does an unconditionalputObject(key, body). If a third concurrent writer from a different repo wrote between thegetObjectat line 204 and theputObjectat line 216, it would be silently overwritten. This is a very narrow window requiring three concurrent registrations and is pre-existing behavior (unchanged by this PR). Noting for completeness.heartbeatIdtype change is a no-op in Deno —gcs_lock.ts:93ReturnType<typeof setInterval>resolves tonumberin Deno, so this is functionally identical. It's a correct portability improvement — no issue, just confirming it's inert.Verdict
PASS — The core change is sound: adding a read-before-write guard in
registerNamespacecorrectly catches the sequential conflict case on GCS-compatible backends that don't enforce generation preconditions. TheputObjectConditionalfallback remains as the primary atomic guard on compliant backends. The type fix ingcs_lock.tsis correct and trivial. Test coverage is good — the newcreateMockGcsServerNoPreconditionsaccurately simulates the target scenario, and tests cover conflict detection, idempotent re-registration, and prefix configurations for both compliant and non-compliant backends. The TOCTOU gap for concurrent writers on non-compliant backends is pre-existing and not worsened by this PR; those backends (fake-gcs-server) are development-only where concurrent registration races are unlikely.Code Review
Blocking Issues
gcs_lock.ts:226-232—forceReleasehas a TOCTOU race (unconditional delete)readLock()callsgcs.getObject()but discards the returnedgeneration:Then
forceReleasedoes an unconditional delete after checking the nonce:Between the read and the delete, the original lock can expire and a new holder can legitimately acquire the same key. The unconditional delete would then remove the new holder's valid lock. Every other delete in this file uses
ifGenerationMatchto prevent exactly this.readLock()should also return the generation (orforceReleaseshould use a separate read that captures it), and the delete should useifGenerationMatch: generation.Note: the existing
gcs_lock_test.tsdoes coverforceRelease, but the mock doesn't exercise the concurrent-acquisition race, so it currently passes despite the bug.Suggestions
gcs.ts:32—jsr:@std/path@1uses a major-version range, not an exact pinThe test file uses
jsr:@std/assert@1.0.19(exact). For consistency with the project's "pin to exact versions" convention, pin@std/pathto a full version (e.g.,jsr:@std/path@1.0.9). Thedeno.lockfile will resolve it to exact, but the specifier itself should be explicit.gcs.ts:163—namespaceis interpolated into GCS key without sanitizationIf upstream code ever passes a namespace containing
..or embedded slashes, the resulting key escapes the intended namespace scope. The rest of the implementation is well-hardened; a guard assertion (e.g., rejecting namespaces containing/) or a brief invariant comment would make the assumed constraint explicit.Code Review
Blocking Issues
forceReleasethrows on expected race — violatesPromise<boolean>contract (gcs_lock.ts:231–235)The
deleteObjectcall is not wrapped in try/catch. WhenifGenerationMatchfails (HTTP 412 Precondition Failed) because the lock generation changed between thereadLockWithGeneration()call and thedeleteObject()call, the method throws instead of returningfalse. This is a new failure mode introduced by this PR: the old unconditionaldeleteObjectcould only throw on network errors, whereas anifGenerationMatch412 is an expected race outcome that callers cannot distinguish from a real error.The method signature is
Promise<boolean>, and callers reasonably expectfalsewhen "the force release didn't happen because the lock is gone or different." A 412 should map toreturn false, notthrow. Comparerelease(), which already wrapsdeleteObjectin try/catch with aconsole.warnfallback.forceReleaseshould do the same or catch and returnfalseon 412.Suggestions
forceReleaseunconditional-delete fallback (gcs_lock.ts:233): Whenresult.generationisundefined, the code passesundefinedas options, resulting in an unconditionaldeleteObjectwith no generation guard. The nonce check happened on the read, but between the read and the delete another process could have acquired a new lock (different nonce). The unconditional delete would then silently remove that process's lock and returntrueincorrectly. In practice real GCS always returns a generation, so this is low risk — but logging a warning or returningfalsein the!result.generationbranch would make the code safer by design.createMockGcsServerandcreateMockGcsServerNoPreconditionscode duplication (gcs_test.ts:169–347): The two helpers share almost identical GET and bucket-check handlers; only the PUT handler differs. A single helper accepting a{ enforcePreconditions: boolean }option would cut ~80 lines and reduce drift. Not a blocker, but the current duplication makes the mock harder to maintain as the API surface grows.Adversarial Review
Medium
gcs_lock.ts:226-236—forceReleasethrowsPreconditionFailedErrorinstead of returningfalsewhen the lock holder heartbeats between read and delete.The TOCTOU fix correctly uses
ifGenerationMatchon the delete, butdeleteObjectdoes not catchPreconditionFailedError(it only swallowsNotFoundError). If the lock holder callsextend()betweenreadLockWithGeneration()anddeleteObject(), the generation changes, the conditional delete throwsPreconditionFailedError, and the exception propagates to the caller. The method's return type isPromise<boolean>— an uncaught exception violates this contract.Breaking scenario: Admin inspects a stuck lock, gets the nonce, calls
forceRelease(nonce). Between the read and delete, the lock holder's heartbeat fires and extends the lock (new generation).deleteObjectthrowsPreconditionFailedError— the admin's tooling crashes with an unhandled exception instead of receivingfalse.Suggested fix:
This requires importing
PreconditionFailedErrorfromgcs_client.ts.gcs_lock_test.ts:123-128(unchanged file, but affects coverage of the changed code) — MockgetObjectnever returnsgeneration, so theforceReleasetest at line 293 never exercises the conditional delete path.The mock's
getObjectreturns{ data }without agenerationfield.readLockWithGeneration()will always seegeneration: undefined, falling back to the unconditional delete branch. The test passes, but it's testing the pre-fix behavior (unconditional delete), not the fix (conditional delete with generation). This means finding #1 above is completely untested.(Note: the mock file itself is not in the changed-files list, so this is flagged as a coverage gap in the PR's test changes, not a blocking issue in an unchanged file.)
Low
gcs.ts:163— Empty-string namespace passes validation but produces a semantically broken registration.The regex
/[/\\]|^\.\.?$|\.\.[\\/]/does not match an empty string.registerNamespace("", repoId)succeeds, creating key/.namespace.json(leading slash).listNamespacesthen filters it out via theif (ns)guard at line 240, so the namespace is registered but invisible. Unlikely in practice — callers probably validate upstream — but the validator could addnamespace.length === 0to the check for completeness.gcs.ts:214— Race-recovery path afterputObjectConditionalreturns null does not handleNotFoundErrorfromgetObject.If
putObjectConditionalreturns null (object existed) but the object is deleted before the re-checkgetObjectcall on line 214,NotFoundErrorpropagates uncaught. The initial read-before-write path (line 173) handlesNotFoundErrorgracefully, but this path does not. Extremely unlikely race — requires the competing writer to register and unregister between lines 211 and 214.Verdict
PASS — The core fixes (forceRelease TOCTOU mitigation via generation-conditional delete, namespace path-traversal guard, read-before-write for non-compliant backends, and dependency pinning) are well-structured and the test additions cover the important cases. The medium findings are real contract/coverage gaps but do not cause data loss or security issues in production paths —
forceReleaseis an administrative escape hatch, and the generation mismatch scenario means the lock holder is actively heartbeating (i.e., not actually stuck), so throwing is arguably safer than silently deleting.Code Review
Blocking Issues
None.
Suggestions
Copyright header inconsistency (
gcs.tsline 2 vsgcs_lock.tsline 2):gcs.tsuses "Elder Swamp Club, Inc." whilegcs_lock.tsuses "System Initiative, Inc." Pick one entity name and use it consistently across the datastore files.createLock/createVerifier/createSyncServiceeach instantiate a freshGcsClient(gcs.tslines 129, 135, 141): EachGcsClientbrings its own connection pool. For tight-loop callers this can add overhead. Sharing a singleGcsClientat provider-construction time — or at least a lazy singleton — would be more efficient. Not a correctness problem, just a resource-use note.while (true)inacquire()does not have an explicitreturnpath for the timeout branch: the timeout is handled bythrow new LockTimeoutError(...), so this is fine as written, but a future reader might expect abreakor loop condition. Considerwhile (Date.now() - startTime < this.maxWaitMs)for clarity (the timeout read at the top of the loop and the finalthrowafter the loop), though again — not a correctness issue.gcs_test.tsmock server always returnsgeneration: "1": the re-registration test path callsputObjectafter the read-before-write check, but the mock always returns the same static generation. This is adequate for correctness testing here, but it means the test cannot catch bugs in code paths that depend on the returned generation (the namespace registration code doesn't use it, so this is fine — just noting the mock fidelity limit).Adversarial Review
Reviewed files:
codegen/designs/gcp.md,codegen/gcp/pipeline.ts,datastore/gcs/extensions/datastores/_lib/gcs_lock.ts,datastore/gcs/extensions/datastores/gcs.ts,datastore/gcs/extensions/datastores/gcs_test.ts,datastore/gcs/manifest.yaml. Allmodel/files skipped (auto-generated).Critical / High
No critical or high severity issues found.
Medium
codegen/gcp/pipeline.ts:506-546 — Removed cross-version resource deduplication; nondeterministic processing orderThe old code sorted schema files (preferred before additional) and tracked
seenResourceKeysto prevent duplicate resources across versions. The new code iteratesDeno.readDir(no guaranteed ordering) and pushes all resources from all schema files without any deduplication.For the current
ADDITIONAL_VERSIONS(onlyiam: ["v1"]), this is safe because IAM v1 and v2 have disjoint resource paths. However, if a futureADDITIONAL_VERSIONSentry has overlapping resource paths with the preferred version, both copies would be included inallResources, producing duplicate model files. Which copy "wins" on disk depends onDeno.readDiriteration order — nondeterministic.The
ADDITIONAL_VERSION_RESOURCE_FILTERexists as a manual safeguard but is empty and unused. The CLAUDE.md instructs "Run generation a second time to verify idempotency — there should be zero new diffs on the second run." IfDeno.readDirorder varies between runs for a service spanning multiple schema files, resource ordering in the models array could change, potentially affecting manifest file listings.Suggested fix: Either sort the directory entries by name before processing (restoring deterministic order), or keep the dedup set as a safety net. A one-liner
entries.sort((a, b) => a.name.localeCompare(b.name))before the processing loop would suffice.codegen/gcp/pipeline.ts:514-516 — Service filter false positive for APIs whose names share a prefix with ADDITIONAL_VERSIONS keysThe new service filter uses
serviceName.startsWith(\${base}-`)to detect additional-version files. If a GCP API existed with a name likeiam-admin, runningdeno task generate:gcp iamwould also processiam-admin.json, because"iam-admin".startsWith("iam-")is true. The old regexADDITIONAL_VERSION_FILENAME_RE = /^(.+)-(v\d+)$/only matched version-like suffixes (-v1,-v2), soiam-adminwould not have been treated as an additional version ofiam`.No current GCP API names use hyphens so this is not actively broken, but it's a latent defect.
Suggested fix: Use a more precise check, e.g.
ADDITIONAL_VERSIONS[base]?.some(v => serviceName === \${base}-${v}`)instead ofstartsWith`.Low
datastore/gcs/extensions/datastores/gcs.ts:163 — Empty namespace string passes validationThe regex
/[/\\]|^\.\.?$|\.\.[\\/]/does not reject empty strings. An empty namespace would produce the object key/.namespace.json. While callers likely validate before reaching this code, the guard could be more defensive.Example:
await provider.registerNamespace("/tmp/ds", "", "repo-aaa")succeeds silently and creates a leading-slash key.datastore/gcs/extensions/datastores/_lib/gcs_lock.ts:232-236 —forceReleasefalls back to unconditional delete when generation is undefinedIf
getObjectdoesn't return a generation header (e.g., a non-standard GCS-compatible backend),result.generationisundefinedand the ternary passesundefinedoptions todeleteObject, performing an unconditional delete. This is the same as the old behavior so it's not a regression, but it defeats the purpose of the generation-based guard on that code path.Verdict
PASS — The datastore changes (read-before-write namespace registration, generation-conditional
forceRelease) are solid improvements with good test coverage. The codegen changes are a reasonable simplification from auto-discovery to an explicit version map. The medium concerns are about future-proofing and determinism, not current correctness.