feat(s3,gcs): namespace-scoped sync for multi-repo shared datastores (#533) #26

Merged
stack72 merged 1 commit from worktree-533 into main 2026-06-03 13:28:11 +00:00
Owner

Summary

Implements namespace-scoped sync in the S3 and GCS datastore extensions, completing the extension side of giga-swamp (swamp-club#525). When options.namespace is set, index operations scope to {namespace}/.datastore-index.json and data walks restrict to the namespace subtree. Three new methods support cross-namespace catalog exchange.

Closes swamp-club#533.

What changed

Interfaces (interfaces.ts in both extensions):

  • DatastoreSyncOptions.namespace?: string
  • SyncCapabilities.namespacedSync?: boolean
  • CatalogExportRow, CatalogExportEntry types
  • Three new optional methods on DatastoreSyncService: exportCatalog, pullForeignCatalogs, fetchForeignContent

Sync service (s3_cache_sync.ts, gcs_cache_sync.ts):

  • bindNamespace() — stores namespace from first pullChanged/pushChanged call, asserts immutability for the instance lifetime
  • indexKey() — returns {namespace}/.datastore-index.json or global .datastore-index.json
  • All hardcoded .datastore-index.json S3/GCS operations updated to use indexKey()
  • discoverIndexFromBucket scoped to listAllObjects({namespace}/) when namespace set
  • exportCatalog — PUT catalog JSON to {namespace}/.catalog-export.json
  • pullForeignCatalogs — GET foreign catalogs, skip missing/malformed JSON silently
  • fetchForeignContent — GET single file from foreign namespace with path traversal validation
  • capabilities() returns namespacedSync: true

Solo mode is fully backward compatible — no namespace means global index, identical to before.

Why it's correct

  1. Namespace immutability: A sync service instance is created once per command lifecycle. bindNamespace() stores the namespace from the first call and throws on mismatch — prevents cross-contamination without needing Map<namespace, state>.

  2. Foreign methods bypass cached state: exportCatalog, pullForeignCatalogs, and fetchForeignContent construct S3/GCS keys directly — they don't touch the index cache, sidecar, or namespace assertion. This is correct because foreign operations read from other namespaces, not the bound one.

  3. Discovery scoping: discoverIndexFromBucket uses listAllObjects({namespace}/) so synthesized indexes only contain the current namespace's keys — no cross-namespace entries.

  4. Path traversal defense: fetchForeignContent rejects paths with ../ segments or absolute paths before constructing the storage key.

Verification

Unit tests (179 total, 0 failed)

  • S3: 91 passed (12 new namespace tests + all existing solo-mode tests)
  • GCS: 88 passed (12 new namespace tests + all existing solo-mode tests)

E2E via swamp CLI against MinIO (21 passed, 0 failed)

✓ Solo repo push/pull round-trip
✓ Solo fast-path (zero-diff)
✓ Namespaced push/pull round-trip (per-namespace index, data under prefix)
✓ Namespaced fast-path
✓ Two namespaces, same bucket (isolated indexes, isolated data)
✓ Cross-namespace deletion safety (security survives infra push)
✓ CRITICAL PATH: wipe cache → pull → swamp data list returns 4 items ★
✓ Namespace isolation (infra/ NOT in security cache)
✓ Sidecars: per-namespace ETags, both clean

Files changed

 12 files changed, 783 insertions(+), 28 deletions(-)

Side finding

Filed swamp-club#534: swamp datastore sync --push was not threading namespace into pushChanged() options, creating a spurious global index. Fixed in swamp 20260603.013237.0.


Co-Authored-By: stack72 paul@systeminit.com
Co-Authored-By: Claude Opus 4.6 (1M context) noreply@anthropic.com

## Summary Implements namespace-scoped sync in the S3 and GCS datastore extensions, completing the extension side of giga-swamp (swamp-club#525). When `options.namespace` is set, index operations scope to `{namespace}/.datastore-index.json` and data walks restrict to the namespace subtree. Three new methods support cross-namespace catalog exchange. Closes swamp-club#533. ## What changed **Interfaces** (`interfaces.ts` in both extensions): - `DatastoreSyncOptions.namespace?: string` - `SyncCapabilities.namespacedSync?: boolean` - `CatalogExportRow`, `CatalogExportEntry` types - Three new optional methods on `DatastoreSyncService`: `exportCatalog`, `pullForeignCatalogs`, `fetchForeignContent` **Sync service** (`s3_cache_sync.ts`, `gcs_cache_sync.ts`): - `bindNamespace()` — stores namespace from first `pullChanged`/`pushChanged` call, asserts immutability for the instance lifetime - `indexKey()` — returns `{namespace}/.datastore-index.json` or global `.datastore-index.json` - All hardcoded `.datastore-index.json` S3/GCS operations updated to use `indexKey()` - `discoverIndexFromBucket` scoped to `listAllObjects({namespace}/)` when namespace set - `exportCatalog` — PUT catalog JSON to `{namespace}/.catalog-export.json` - `pullForeignCatalogs` — GET foreign catalogs, skip missing/malformed JSON silently - `fetchForeignContent` — GET single file from foreign namespace with path traversal validation - `capabilities()` returns `namespacedSync: true` **Solo mode is fully backward compatible** — no namespace means global index, identical to before. ## Why it's correct 1. **Namespace immutability**: A sync service instance is created once per command lifecycle. `bindNamespace()` stores the namespace from the first call and throws on mismatch — prevents cross-contamination without needing `Map<namespace, state>`. 2. **Foreign methods bypass cached state**: `exportCatalog`, `pullForeignCatalogs`, and `fetchForeignContent` construct S3/GCS keys directly — they don't touch the index cache, sidecar, or namespace assertion. This is correct because foreign operations read from other namespaces, not the bound one. 3. **Discovery scoping**: `discoverIndexFromBucket` uses `listAllObjects({namespace}/)` so synthesized indexes only contain the current namespace's keys — no cross-namespace entries. 4. **Path traversal defense**: `fetchForeignContent` rejects paths with `../` segments or absolute paths before constructing the storage key. ## Verification ### Unit tests (179 total, 0 failed) - S3: 91 passed (12 new namespace tests + all existing solo-mode tests) - GCS: 88 passed (12 new namespace tests + all existing solo-mode tests) ### E2E via swamp CLI against MinIO (21 passed, 0 failed) ``` ✓ Solo repo push/pull round-trip ✓ Solo fast-path (zero-diff) ✓ Namespaced push/pull round-trip (per-namespace index, data under prefix) ✓ Namespaced fast-path ✓ Two namespaces, same bucket (isolated indexes, isolated data) ✓ Cross-namespace deletion safety (security survives infra push) ✓ CRITICAL PATH: wipe cache → pull → swamp data list returns 4 items ★ ✓ Namespace isolation (infra/ NOT in security cache) ✓ Sidecars: per-namespace ETags, both clean ``` ### Files changed ``` 12 files changed, 783 insertions(+), 28 deletions(-) ``` ## Side finding Filed swamp-club#534: `swamp datastore sync --push` was not threading namespace into `pushChanged()` options, creating a spurious global index. Fixed in swamp `20260603.013237.0`. --- Co-Authored-By: stack72 <paul@systeminit.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
feat(s3,gcs): add namespace-scoped sync for multi-repo shared datastores (#533)
Some checks failed
CI / model/hetzner-cloud - check (pull_request) Has been skipped
CI / cloudflare models - lockfiles up to date (pull_request) Has been skipped
CI / workflows/s3-bootstrap - lint (pull_request) Has been skipped
CI / workflows/s3-bootstrap - lockfile up to date (pull_request) Has been skipped
CI / cve/dirtyfrag - check (pull_request) Has been skipped
CI / workflows/s3-bootstrap - fmt (pull_request) Has been skipped
CI / codegen - check (pull_request) Has been skipped
CI / workflows/s3-bootstrap - test (pull_request) Has been skipped
CI / workflows/gcs-bootstrap - test (pull_request) Has been skipped
CI / workflows/gcs-bootstrap - lockfile up to date (pull_request) Has been skipped
CI / codegen - fmt (pull_request) Has been skipped
CI / cve/dirtyfrag - fmt (pull_request) Has been skipped
CI / cve/dirtyfrag - lint (pull_request) Has been skipped
CI / cve/dirtyfrag - test (pull_request) Has been skipped
CI / cve/dirtyfrag - lockfile up to date (pull_request) Has been skipped
CI / cve/mini-shai-hulud - lockfile up to date (pull_request) Has been skipped
CI / codegen - lint (pull_request) Has been skipped
CI / model/digitalocean - check (pull_request) Has been skipped
CI / codegen - lockfile up to date (pull_request) Has been skipped
CI / CI Security Review (pull_request) Has been skipped
CI / cve/mini-shai-hulud - lint (pull_request) Has been skipped
CI / workflows/s3-bootstrap - check (pull_request) Has been skipped
CI / cve/mini-shai-hulud - test (pull_request) Has been skipped
CI / workflows/gcs-bootstrap - fmt (pull_request) Has been skipped
CI / cve/mini-shai-hulud - fmt (pull_request) Has been skipped
CI / workflows/gcs-bootstrap - lint (pull_request) Has been skipped
CI / cve/mini-shai-hulud - check (pull_request) Has been skipped
CI / Adversarial Code Review (pull_request) Failing after 8m2s
CI / Claude Code Review (pull_request) Successful in 9m21s
CI / Merge Gate (pull_request) Has started running
e79026fe6f
When options.namespace is set, scope index operations to
{namespace}/.datastore-index.json and restrict data walks to the
namespace subtree. Add exportCatalog, pullForeignCatalogs, and
fetchForeignContent methods for cross-namespace catalog exchange.

Solo mode (no namespace) is fully backward compatible — all existing
tests pass unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author
Owner

Adversarial Review

Critical / High

  1. CRITICAL -- SSH regression: trailing newline fix reverted (ssh/extensions/models/_lib/operations.ts:313, ssh/extensions/models/ssh.ts:93, ssh/manifest.yaml:3)

    Origin/main contains fix 511/25 at SSH version 2026.06.03.1 which ensures identityContent ends with a trailing newline before writing to the temp file. This PR removes that logic, downgrades the version to 2026.06.01.2, removes the upgrade entry, and deletes the corresponding test.

    How it breaks: OpenSSH rejects PEM private keys without a trailing newline with "Load key: invalid format". If a vault-stored key has its final newline stripped (common when secrets are stored or retrieved via APIs that trim whitespace), every exec, script, and copy call against that host will fail.

    Concrete example: A vault stores a key ending with "-----END OPENSSH PRIVATE KEY-----" (no trailing newline). Before this PR, materializeTempKeys appended the newline. After this PR, the key is written as-is, and ssh fails with "Load key: invalid format".

    Fix: Either revert the SSH changes entirely (they appear unrelated to the namespace-scoped sync feature), or provide a different fix for the underlying issue. The version downgrade from 2026.06.03.1 to 2026.06.01.2 is also a manifest regression that would confuse the publishing pipeline.

Medium

  1. MEDIUM -- No validation on namespace parameter (s3_cache_sync.ts:479, gcs_cache_sync.ts:438, plus exportCatalog, pullForeignCatalogs, fetchForeignContent in both files)

    The namespace string is interpolated directly into object keys with zero validation across all methods: bindNamespace, indexKey, exportCatalog, pullForeignCatalogs, and fetchForeignContent. While S3/GCS keys are opaque strings (no real path traversal), edge cases produce surprising behavior:

    • Empty string: bindNamespace with empty string sets namespaceBound=true with namespace="". Since indexKey uses a truthy check, empty string falls through to solo mode. But subsequent calls with namespace=undefined trigger "Namespace mismatch" because "" !== undefined. A caller passing namespace="" instead of omitting the field permanently poisons the service instance.

    • Slashes or special chars: namespace "a/b" produces keys like "a/b/.datastore-index.json" -- valid but may not match the design intent of a single path prefix.

    Suggested fix: Add a validation check in bindNamespace that rejects empty strings, slashes, and ".." segments, matching the fetchForeignContent path validation pattern.

  2. MEDIUM -- fetchForeignContent validates relPath but not namespace (s3_cache_sync.ts:1687-1692, gcs_cache_sync.ts:1497-1502)

    fetchForeignContent has path traversal protection on relPath (checking for leading slash, backslash, and ".." segments) but applies no validation to the namespace parameter. A call like fetchForeignContent with a namespace containing path-traversal-like segments constructs unexpected keys. While this is harmless in object stores (literal key), it is inconsistent with the security posture on relPath -- both parameters are caller-supplied strings used to build keys.

    Suggested fix: Apply the same traversal checks to namespace that are applied to relPath.

Low

  1. LOW -- pullForeignCatalogs fetches sequentially (s3_cache_sync.ts:1662-1679, gcs_cache_sync.ts:1472-1489)

    The for-of loop fetches each foreign catalog with a separate HTTP request, one at a time. With many foreign namespaces, this creates a serial bottleneck. The existing push/pull paths in the same file use Promise.allSettled with batched concurrency for similar fan-out patterns.

    Concrete example: 10 foreign namespaces x 200ms network latency per GET = 2 seconds of serial waiting.

    Suggested fix: Use Promise.allSettled with the existing concurrency batching pattern.

Verdict

FAIL -- The SSH trailing newline fix revert is a regression that will cause production SSH failures when vault-stored keys lack trailing newlines. The SSH changes appear unrelated to the namespace-scoped sync feature and should be reverted from this PR.

The datastore namespace feature itself is well-structured with good test coverage and a clean separation between namespaced and solo modes. The medium-severity namespace validation gaps are real but non-blocking.

## Adversarial Review ### Critical / High 1. **CRITICAL -- SSH regression: trailing newline fix reverted** (ssh/extensions/models/_lib/operations.ts:313, ssh/extensions/models/ssh.ts:93, ssh/manifest.yaml:3) Origin/main contains fix 511/25 at SSH version 2026.06.03.1 which ensures identityContent ends with a trailing newline before writing to the temp file. This PR removes that logic, downgrades the version to 2026.06.01.2, removes the upgrade entry, and deletes the corresponding test. **How it breaks:** OpenSSH rejects PEM private keys without a trailing newline with "Load key: invalid format". If a vault-stored key has its final newline stripped (common when secrets are stored or retrieved via APIs that trim whitespace), every exec, script, and copy call against that host will fail. **Concrete example:** A vault stores a key ending with "-----END OPENSSH PRIVATE KEY-----" (no trailing newline). Before this PR, materializeTempKeys appended the newline. After this PR, the key is written as-is, and ssh fails with "Load key: invalid format". **Fix:** Either revert the SSH changes entirely (they appear unrelated to the namespace-scoped sync feature), or provide a different fix for the underlying issue. The version downgrade from 2026.06.03.1 to 2026.06.01.2 is also a manifest regression that would confuse the publishing pipeline. ### Medium 1. **MEDIUM -- No validation on namespace parameter** (s3_cache_sync.ts:479, gcs_cache_sync.ts:438, plus exportCatalog, pullForeignCatalogs, fetchForeignContent in both files) The namespace string is interpolated directly into object keys with zero validation across all methods: bindNamespace, indexKey, exportCatalog, pullForeignCatalogs, and fetchForeignContent. While S3/GCS keys are opaque strings (no real path traversal), edge cases produce surprising behavior: - **Empty string**: bindNamespace with empty string sets namespaceBound=true with namespace="". Since indexKey uses a truthy check, empty string falls through to solo mode. But subsequent calls with namespace=undefined trigger "Namespace mismatch" because "" !== undefined. A caller passing namespace="" instead of omitting the field permanently poisons the service instance. - **Slashes or special chars**: namespace "a/b" produces keys like "a/b/.datastore-index.json" -- valid but may not match the design intent of a single path prefix. **Suggested fix:** Add a validation check in bindNamespace that rejects empty strings, slashes, and ".." segments, matching the fetchForeignContent path validation pattern. 2. **MEDIUM -- fetchForeignContent validates relPath but not namespace** (s3_cache_sync.ts:1687-1692, gcs_cache_sync.ts:1497-1502) fetchForeignContent has path traversal protection on relPath (checking for leading slash, backslash, and ".." segments) but applies no validation to the namespace parameter. A call like fetchForeignContent with a namespace containing path-traversal-like segments constructs unexpected keys. While this is harmless in object stores (literal key), it is inconsistent with the security posture on relPath -- both parameters are caller-supplied strings used to build keys. **Suggested fix:** Apply the same traversal checks to namespace that are applied to relPath. ### Low 1. **LOW -- pullForeignCatalogs fetches sequentially** (s3_cache_sync.ts:1662-1679, gcs_cache_sync.ts:1472-1489) The for-of loop fetches each foreign catalog with a separate HTTP request, one at a time. With many foreign namespaces, this creates a serial bottleneck. The existing push/pull paths in the same file use Promise.allSettled with batched concurrency for similar fan-out patterns. **Concrete example:** 10 foreign namespaces x 200ms network latency per GET = 2 seconds of serial waiting. **Suggested fix:** Use Promise.allSettled with the existing concurrency batching pattern. ### Verdict **FAIL** -- The SSH trailing newline fix revert is a regression that will cause production SSH failures when vault-stored keys lack trailing newlines. The SSH changes appear unrelated to the namespace-scoped sync feature and should be reverted from this PR. The datastore namespace feature itself is well-structured with good test coverage and a clean separation between namespaced and solo modes. The medium-severity namespace validation gaps are real but non-blocking.
Author
Owner

Code Review

Blocking Issues

None.

Suggestions

  1. Push test doesn't validate that data keys are namespace-prefixed (gcs_cache_sync_test.ts:3441, s3_cache_sync_test.ts:3963): The "pushChanged with namespace writes per-namespace index" test seeds local files at cachePath/data/model/1/raw (no namespace) and only asserts the INDEX ends up at my-ns/.datastore-index.json. It doesn't verify that data files land at my-ns/data/model/1/raw in the bucket. The pull test and fetchForeignContent both assume data IS at namespace-prefixed GCS paths (gcs.storage.set("my-ns/data/model/1/raw", ...)). In practice a push with no-namespace-prefixed local files would produce GCS keys like data/model/1/raw, but fetchForeignContent("my-ns", "data/model/1/raw") would look for my-ns/data/model/1/raw — not found. The tests are internally consistent with themselves but do not together exercise the round-trip (push namespace → fetchForeignContent namespace). Consider adding a round-trip test, or clarifying the docstring on DatastoreSyncOptions.namespace to say whether namespace scopes data paths or only the index.

  2. namespace parameter in exportCatalog and pullForeignCatalogs is unvalidated (gcs_cache_sync.ts:1463, s3_cache_sync.ts:1652): The relPath parameter in fetchForeignContent is validated for path traversal, but the namespace parameter is not. A namespace containing .. would produce a GCS key like ../evil/.catalog-export.json. Cloud storage APIs treat object names as opaque strings so there's no filesystem-level traversal risk, but a defensive if (namespace.includes("..") || namespace.startsWith("/")) guard would be consistent with fetchForeignContent's treatment of relPath and prevent accidentally scoping to an unintended prefix. Since namespace is trust-internal this is low severity.

  3. writePartitionedIndex type inconsistency between GCS and S3 (gcs_cache_sync.ts:1384): GCS uses Array<Promise<unknown>> = [] while S3 uses Promise<void>[]. Both work correctly because errors are swallowed, but using Promise<void>[] in both (and keeping the .then(() => {}) pattern like S3 does) would make the two implementations consistent with each other.

  4. SSH manifest.yaml doesn't include a version bump rationale in this PR's context (ssh/manifest.yaml): The ssh extension version changed to 2026.06.01.2 alongside the s3/gcs changes. There are no functional ssh changes visible in the diff that would explain a version bump in the same PR as the datastore namespace feature. If the ssh version was bumped solely to pick up infrastructure changes (e.g. updated test scaffolding), a brief note in the PR description about what drove that bump would help future reviewers understand why ssh changed in a datastore PR. This is a documentation nit only — the code itself looks correct.

## Code Review ### Blocking Issues None. ### Suggestions 1. **Push test doesn't validate that data keys are namespace-prefixed** (`gcs_cache_sync_test.ts:3441`, `s3_cache_sync_test.ts:3963`): The "pushChanged with namespace writes per-namespace index" test seeds local files at `cachePath/data/model/1/raw` (no namespace) and only asserts the INDEX ends up at `my-ns/.datastore-index.json`. It doesn't verify that data files land at `my-ns/data/model/1/raw` in the bucket. The pull test and `fetchForeignContent` both assume data IS at namespace-prefixed GCS paths (`gcs.storage.set("my-ns/data/model/1/raw", ...)`). In practice a push with no-namespace-prefixed local files would produce GCS keys like `data/model/1/raw`, but `fetchForeignContent("my-ns", "data/model/1/raw")` would look for `my-ns/data/model/1/raw` — not found. The tests are internally consistent with themselves but do not together exercise the round-trip (`push` namespace → `fetchForeignContent` namespace). Consider adding a round-trip test, or clarifying the docstring on `DatastoreSyncOptions.namespace` to say whether namespace scopes data paths or only the index. 2. **`namespace` parameter in `exportCatalog` and `pullForeignCatalogs` is unvalidated** (`gcs_cache_sync.ts:1463`, `s3_cache_sync.ts:1652`): The `relPath` parameter in `fetchForeignContent` is validated for path traversal, but the `namespace` parameter is not. A namespace containing `..` would produce a GCS key like `../evil/.catalog-export.json`. Cloud storage APIs treat object names as opaque strings so there's no filesystem-level traversal risk, but a defensive `if (namespace.includes("..") || namespace.startsWith("/"))` guard would be consistent with `fetchForeignContent`'s treatment of `relPath` and prevent accidentally scoping to an unintended prefix. Since namespace is trust-internal this is low severity. 3. **`writePartitionedIndex` type inconsistency between GCS and S3** (`gcs_cache_sync.ts:1384`): GCS uses `Array<Promise<unknown>> = []` while S3 uses `Promise<void>[]`. Both work correctly because errors are swallowed, but using `Promise<void>[]` in both (and keeping the `.then(() => {})` pattern like S3 does) would make the two implementations consistent with each other. 4. **SSH `manifest.yaml` doesn't include a version bump rationale in this PR's context** (`ssh/manifest.yaml`): The ssh extension version changed to `2026.06.01.2` alongside the s3/gcs changes. There are no functional ssh changes visible in the diff that would explain a version bump in the same PR as the datastore namespace feature. If the ssh version was bumped solely to pick up infrastructure changes (e.g. updated test scaffolding), a brief note in the PR description about what drove that bump would help future reviewers understand why ssh changed in a datastore PR. This is a documentation nit only — the code itself looks correct.
stack72 force-pushed worktree-533 from e79026fe6f
Some checks failed
CI / model/hetzner-cloud - check (pull_request) Has been skipped
CI / cloudflare models - lockfiles up to date (pull_request) Has been skipped
CI / workflows/s3-bootstrap - lint (pull_request) Has been skipped
CI / workflows/s3-bootstrap - lockfile up to date (pull_request) Has been skipped
CI / cve/dirtyfrag - check (pull_request) Has been skipped
CI / workflows/s3-bootstrap - fmt (pull_request) Has been skipped
CI / codegen - check (pull_request) Has been skipped
CI / workflows/s3-bootstrap - test (pull_request) Has been skipped
CI / workflows/gcs-bootstrap - test (pull_request) Has been skipped
CI / workflows/gcs-bootstrap - lockfile up to date (pull_request) Has been skipped
CI / codegen - fmt (pull_request) Has been skipped
CI / cve/dirtyfrag - fmt (pull_request) Has been skipped
CI / cve/dirtyfrag - lint (pull_request) Has been skipped
CI / cve/dirtyfrag - test (pull_request) Has been skipped
CI / cve/dirtyfrag - lockfile up to date (pull_request) Has been skipped
CI / cve/mini-shai-hulud - lockfile up to date (pull_request) Has been skipped
CI / codegen - lint (pull_request) Has been skipped
CI / model/digitalocean - check (pull_request) Has been skipped
CI / codegen - lockfile up to date (pull_request) Has been skipped
CI / CI Security Review (pull_request) Has been skipped
CI / cve/mini-shai-hulud - lint (pull_request) Has been skipped
CI / workflows/s3-bootstrap - check (pull_request) Has been skipped
CI / cve/mini-shai-hulud - test (pull_request) Has been skipped
CI / workflows/gcs-bootstrap - fmt (pull_request) Has been skipped
CI / cve/mini-shai-hulud - fmt (pull_request) Has been skipped
CI / workflows/gcs-bootstrap - lint (pull_request) Has been skipped
CI / cve/mini-shai-hulud - check (pull_request) Has been skipped
CI / Adversarial Code Review (pull_request) Failing after 8m2s
CI / Claude Code Review (pull_request) Successful in 9m21s
CI / Merge Gate (pull_request) Has started running
to b4929f30d1
All checks were successful
CI / workflows/s3-bootstrap - lockfile up to date (pull_request) Has been skipped
CI / cve/dirtyfrag - test (pull_request) Has been skipped
CI / cve/dirtyfrag - check (pull_request) Has been skipped
CI / cve/mini-shai-hulud - check (pull_request) Has been skipped
CI / cve/dirtyfrag - fmt (pull_request) Has been skipped
CI / cve/mini-shai-hulud - fmt (pull_request) Has been skipped
CI / workflows/gcs-bootstrap - lockfile up to date (pull_request) Has been skipped
CI / cve/mini-shai-hulud - lint (pull_request) Has been skipped
CI / cve/mini-shai-hulud - test (pull_request) Has been skipped
CI / cve/mini-shai-hulud - lockfile up to date (pull_request) Has been skipped
CI / cve/dirtyfrag - lockfile up to date (pull_request) Has been skipped
CI / Dependency Audit (pull_request) Successful in 5m0s
CI / model/digitalocean - check (pull_request) Has been skipped
CI / model/hetzner-cloud - check (pull_request) Has been skipped
CI / model/digitalocean - lockfile up to date (pull_request) Has been skipped
CI / model/hetzner-cloud - lockfile up to date (pull_request) Has been skipped
CI / aws models - sample check (pull_request) Has been skipped
CI / aws models - lockfiles up to date (pull_request) Has been skipped
CI / gcp models - sample check (pull_request) Has been skipped
CI / gcp models - lockfiles up to date (pull_request) Has been skipped
CI / cloudflare models - sample check (pull_request) Has been skipped
CI / cloudflare models - lockfiles up to date (pull_request) Has been skipped
CI / codegen - check (pull_request) Has been skipped
CI / codegen - fmt (pull_request) Has been skipped
CI / codegen - lint (pull_request) Has been skipped
CI / CI Security Review (pull_request) Has been skipped
CI / codegen - lockfile up to date (pull_request) Has been skipped
CI / Claude Code Review (pull_request) Successful in 6m46s
CI / Adversarial Code Review (pull_request) Successful in 6m46s
CI / Merge Gate (pull_request) Successful in 28s
2026-06-03 12:57:07 +00:00
Compare
Author
Owner

Code Review

Blocking Issues

None.

Suggestions

  1. isLazySkippable and partitionKeyFromPath are namespace-unaware, silently breaking metadataOnly and scoped sync in namespace mode.

    Both functions check parts[0] === "data", but in namespace mode the index entries carry the namespace prefix (e.g. my-ns/data/model/1/raw), so parts[0] is my-ns. Consequently:

    • isLazySkippable("my-ns/data/model/1/raw") returns falsemetadataOnly: true silently downloads all files, defeating the lazy-hydration optimization.
    • partitionKeyFromPath("my-ns/data/model/1/raw") returns undefined → scoped sync silently falls back to the monolithic index in namespace mode.

    capabilities() advertises scopedSync: true, lazyHydration: true, and namespacedSync: true simultaneously, implying these can be composed. No existing test covers the namespace + metadataOnly or namespace + context.models combinations. If swamp-core combines them, behaviour will be silently wrong. This is consistent across the S3 and GCS implementations.

    Suggested fix: strip the namespace prefix before passing the relative path to isLazySkippable / partitionKeyFromPath (or add namespace-aware variants), and add tests for the cross-feature combinations.

  2. isInternalCacheFile doesn't exclude namespace-prefixed internal files from discoverIndexFromBucket.

    discoverIndexFromBucket lists objects under ${namespace}/, and then filters via isInternalCacheFile. The function checks for the bare form (.datastore-index.json, .datastore-sync-state.json, etc.) but NOT the namespace-prefixed form (my-ns/.catalog-export.json). If exportCatalog has written a my-ns/.catalog-export.json to GCS but the namespace index is later lost and re-discovered, the catalog file appears in the synthesized index entries. Subsequent pullChanged calls would download it as a data file, polluting the local cache.

    The bug manifests in the fairly narrow window where the namespace index is absent but a catalog export exists. Suggested fix: extend isInternalCacheFile (or the filtered predicate in discoverIndexFromBucket) to strip any namespace prefix before pattern-matching, or add .catalog-export.json as a basename exclusion alongside _catalog.db.

  3. writePartitionedIndex writes to the global _index/ prefix rather than a namespace-scoped prefix.

    Partition files are written to _index/{key}.json unconditionally, even in namespace mode. Two namespaces sharing a bucket and containing models with the same type+id would overwrite each other's partition files. Failures here are silently swallowed (.catch(() => {})), so the monolithic index remains correct and there's no data loss, but scoped-sync clients fall back to the monolithic path instead of the cheaper partition path. Worth noting as a follow-up once namespace + scoped-sync is supported (see suggestion 1).

  4. namespace parameter is not validated in exportCatalog, pullForeignCatalogs, and fetchForeignContent.

    A caller could pass a value containing path separators or unusual characters. Because these values are used to construct cloud-storage object keys (not local filesystem paths), there's no local filesystem traversal risk, and the assertSafePath guard covers the local side. However, an untrusted namespace value could create unexpected object-key shapes or collide with internal prefixes. Low severity, but worth adding a basic guard (e.g., reject values containing / or ..) if namespaces are meant to be opaque identifiers.

  5. Minor type inconsistency in writePartitionedIndex.

    GCS uses const writes: Array<Promise<unknown>> = []; while S3 uses const writes: Promise<void>[] = [];. Both appended values are Promise<void> after .catch(() => {}), so Array<Promise<void>> is more precise. Not functionally wrong, but breaks the intended symmetry between the two implementations.

## Code Review ### Blocking Issues None. ### Suggestions 1. **`isLazySkippable` and `partitionKeyFromPath` are namespace-unaware, silently breaking `metadataOnly` and scoped sync in namespace mode.** Both functions check `parts[0] === "data"`, but in namespace mode the index entries carry the namespace prefix (e.g. `my-ns/data/model/1/raw`), so `parts[0]` is `my-ns`. Consequently: - `isLazySkippable("my-ns/data/model/1/raw")` returns `false` → `metadataOnly: true` silently downloads all files, defeating the lazy-hydration optimization. - `partitionKeyFromPath("my-ns/data/model/1/raw")` returns `undefined` → scoped sync silently falls back to the monolithic index in namespace mode. `capabilities()` advertises `scopedSync: true`, `lazyHydration: true`, and `namespacedSync: true` simultaneously, implying these can be composed. No existing test covers the `namespace + metadataOnly` or `namespace + context.models` combinations. If swamp-core combines them, behaviour will be silently wrong. This is consistent across the S3 and GCS implementations. Suggested fix: strip the namespace prefix before passing the relative path to `isLazySkippable` / `partitionKeyFromPath` (or add namespace-aware variants), and add tests for the cross-feature combinations. 2. **`isInternalCacheFile` doesn't exclude namespace-prefixed internal files from `discoverIndexFromBucket`.** `discoverIndexFromBucket` lists objects under `${namespace}/`, and then filters via `isInternalCacheFile`. The function checks for the bare form (`.datastore-index.json`, `.datastore-sync-state.json`, etc.) but NOT the namespace-prefixed form (`my-ns/.catalog-export.json`). If `exportCatalog` has written a `my-ns/.catalog-export.json` to GCS but the namespace index is later lost and re-discovered, the catalog file appears in the synthesized index entries. Subsequent `pullChanged` calls would download it as a data file, polluting the local cache. The bug manifests in the fairly narrow window where the namespace index is absent but a catalog export exists. Suggested fix: extend `isInternalCacheFile` (or the `filtered` predicate in `discoverIndexFromBucket`) to strip any namespace prefix before pattern-matching, or add `.catalog-export.json` as a basename exclusion alongside `_catalog.db`. 3. **`writePartitionedIndex` writes to the global `_index/` prefix rather than a namespace-scoped prefix.** Partition files are written to `_index/{key}.json` unconditionally, even in namespace mode. Two namespaces sharing a bucket and containing models with the same type+id would overwrite each other's partition files. Failures here are silently swallowed (`.catch(() => {})`), so the monolithic index remains correct and there's no data loss, but scoped-sync clients fall back to the monolithic path instead of the cheaper partition path. Worth noting as a follow-up once namespace + scoped-sync is supported (see suggestion 1). 4. **`namespace` parameter is not validated in `exportCatalog`, `pullForeignCatalogs`, and `fetchForeignContent`.** A caller could pass a value containing path separators or unusual characters. Because these values are used to construct cloud-storage object keys (not local filesystem paths), there's no local filesystem traversal risk, and the `assertSafePath` guard covers the local side. However, an untrusted namespace value could create unexpected object-key shapes or collide with internal prefixes. Low severity, but worth adding a basic guard (e.g., reject values containing `/` or `..`) if namespaces are meant to be opaque identifiers. 5. **Minor type inconsistency in `writePartitionedIndex`.** GCS uses `const writes: Array<Promise<unknown>> = [];` while S3 uses `const writes: Promise<void>[] = [];`. Both appended values are `Promise<void>` after `.catch(() => {})`, so `Array<Promise<void>>` is more precise. Not functionally wrong, but breaks the intended symmetry between the two implementations.
Author
Owner

Adversarial Review

Medium

  1. Partition index files are not namespace-scoped — cross-contamination risk on scoped pull

    • Files: s3_cache_sync.ts:1567, s3_cache_sync.ts:1608-1609, gcs_cache_sync.ts:1392, gcs_cache_sync.ts:1422-1423
    • What's wrong: writePartitionedIndex writes to hardcoded _index/{key}.json and pullPartitionedIndex reads from _index/{key}.json, both without namespace prefix. In namespaced mode, the monolithic index correctly scopes to {namespace}/.datastore-index.json, but partition files remain global.
    • Breaking example: Bucket previously used in solo (non-namespaced) mode has partition files at _index/data--aws--ec2--instance.json containing entries keyed as data/aws/ec2/instance/.... A new namespaced client calls pullChanged({ namespace: "team-a", context: { models: [...] } }). The scoped pull path calls pullPartitionedIndex, which reads the global partition files, merges non-namespaced entries (data/aws/ec2/...) into the namespaced in-memory index, and on the next pushChanged, writes them into team-a/.datastore-index.json — contaminating the namespace index with global entries.
    • Mitigating factors: For pure namespaced usage (index entries keyed as ns/data/...), partitionKeyFromPath returns undefined because it checks rel.startsWith("data/"), so partition writes are no-ops and reads fall back to the correct monolithic index. The issue only manifests during solo→namespace migration on the same bucket with scoped sync active.
    • Suggested fix: Prefix partition paths with namespace when set: this.namespace ? `${this.namespace}/_index/${key}.json` : `_index/${key}.json` . Or document that scoped sync is not supported in namespaced mode and skip the pullPartitionedIndex path when namespace is set.
  2. pullForeignCatalogs swallows AbortError from signal cancellation

    • Files: s3_cache_sync.ts:1675, gcs_cache_sync.ts:1485
    • What's wrong: The bare catch {} in the namespace iteration loop catches all errors, including AbortError thrown when the caller's signal fires. Once an AbortSignal is aborted it stays aborted, so every subsequent getObject also throws AbortError, which is also caught. The method returns an empty or partial array instead of propagating the abort.
    • Breaking example: Caller passes an AbortSignal with a 5-second timeout and requests catalogs from ["ns-a", "ns-b", "ns-c"]. Network is slow; the signal fires during the ns-a fetch. The method silently skips all three namespaces and returns []. The caller proceeds as if no foreign catalogs exist — incorrect but no error is raised.
    • Suggested fix: Check for abort before/inside the catch:
      } catch (error) {
        if (signal?.aborted) throw signal.reason ?? new DOMException("Aborted", "AbortError");
        // Missing or malformed — skip silently
      }
      
  3. No validation on namespace parameter in exportCatalog and pullForeignCatalogs

    • Files: s3_cache_sync.ts:1649-1654, s3_cache_sync.ts:1667-1668, gcs_cache_sync.ts:1459-1464, gcs_cache_sync.ts:1478-1479
    • What's wrong: fetchForeignContent validates relPath for path traversal (../, leading /, leading \), but exportCatalog and pullForeignCatalogs use the namespace parameter directly in key construction (${namespace}/.catalog-export.json) with zero validation. The same namespace parameter is also used unvalidated in fetchForeignContent's key construction and in bindNamespace for pullChanged/pushChanged.
    • Breaking example: exportCatalog("", rows) writes to key /.catalog-export.json. exportCatalog("ns/../../other", rows) writes to ns/../../other/.catalog-export.json. While S3/GCS treats keys as opaque strings (no filesystem traversal), inconsistent validation across parameters in the same API surface is a code smell, and empty or malformed namespaces could cause confusing key layouts.
    • Suggested fix: Add a shared assertValidNamespace(ns) that rejects empty strings, strings containing .. segments, leading/trailing slashes, and backslashes. Apply it in bindNamespace, exportCatalog, pullForeignCatalogs, and fetchForeignContent.

Low

  1. Sidecar file is not namespace-scoped — stale fast-path data when cachePath is shared across namespaces
    • Files: s3_cache_sync.ts:466-469, gcs_cache_sync.ts:432-433
    • What's wrong: The sync state sidecar (.datastore-sync-state.json) is stored at join(cachePath, SYNC_STATE_FILE) regardless of namespace. If two different namespaces share a cachePath (e.g., ~/.cache/swamp/datastores/my-store/), the sidecar written by namespace A contains the ETag/generation from ns-a/.datastore-index.json. When namespace B starts and reads the sidecar, it compares against ns-b/.datastore-index.json — the ETags will almost certainly differ, so the fast path falls through to the slow path. The only risk is a vanishingly unlikely ETag collision causing the fast path to incorrectly return 0.
    • In practice: Self-healing. Worst case is one unnecessary slow-path execution per namespace switch. The bindNamespace check prevents within-instance switching.

Verdict

PASS — The namespace-scoped sync feature is well-structured with good test coverage (14 new tests across S3 and GCS). The bindNamespace immutability guard, indexKey() scoping, discoverIndexFromBucket prefix filtering, and fetchForeignContent path traversal check are all solid. The medium findings are edge cases in mixed solo↔namespace migration or abort semantics — none represent data loss or security risk in the primary use case (pure namespaced or pure solo mode). The code is backward compatible for solo mode as claimed.

## Adversarial Review ### Medium 1. **Partition index files are not namespace-scoped — cross-contamination risk on scoped pull** - **Files**: `s3_cache_sync.ts:1567`, `s3_cache_sync.ts:1608-1609`, `gcs_cache_sync.ts:1392`, `gcs_cache_sync.ts:1422-1423` - **What's wrong**: `writePartitionedIndex` writes to hardcoded `_index/{key}.json` and `pullPartitionedIndex` reads from `_index/{key}.json`, both without namespace prefix. In namespaced mode, the monolithic index correctly scopes to `{namespace}/.datastore-index.json`, but partition files remain global. - **Breaking example**: Bucket previously used in solo (non-namespaced) mode has partition files at `_index/data--aws--ec2--instance.json` containing entries keyed as `data/aws/ec2/instance/...`. A new namespaced client calls `pullChanged({ namespace: "team-a", context: { models: [...] } })`. The scoped pull path calls `pullPartitionedIndex`, which reads the global partition files, merges non-namespaced entries (`data/aws/ec2/...`) into the namespaced in-memory index, and on the next `pushChanged`, writes them into `team-a/.datastore-index.json` — contaminating the namespace index with global entries. - **Mitigating factors**: For pure namespaced usage (index entries keyed as `ns/data/...`), `partitionKeyFromPath` returns `undefined` because it checks `rel.startsWith("data/")`, so partition writes are no-ops and reads fall back to the correct monolithic index. The issue only manifests during solo→namespace migration on the same bucket with scoped sync active. - **Suggested fix**: Prefix partition paths with namespace when set: ``this.namespace ? `${this.namespace}/_index/${key}.json` : `_index/${key}.json` ``. Or document that scoped sync is not supported in namespaced mode and skip the `pullPartitionedIndex` path when namespace is set. 2. **`pullForeignCatalogs` swallows `AbortError` from signal cancellation** - **Files**: `s3_cache_sync.ts:1675`, `gcs_cache_sync.ts:1485` - **What's wrong**: The bare `catch {}` in the namespace iteration loop catches all errors, including `AbortError` thrown when the caller's signal fires. Once an `AbortSignal` is aborted it stays aborted, so every subsequent `getObject` also throws `AbortError`, which is also caught. The method returns an empty or partial array instead of propagating the abort. - **Breaking example**: Caller passes an `AbortSignal` with a 5-second timeout and requests catalogs from `["ns-a", "ns-b", "ns-c"]`. Network is slow; the signal fires during the `ns-a` fetch. The method silently skips all three namespaces and returns `[]`. The caller proceeds as if no foreign catalogs exist — incorrect but no error is raised. - **Suggested fix**: Check for abort before/inside the catch: ```typescript } catch (error) { if (signal?.aborted) throw signal.reason ?? new DOMException("Aborted", "AbortError"); // Missing or malformed — skip silently } ``` 3. **No validation on `namespace` parameter in `exportCatalog` and `pullForeignCatalogs`** - **Files**: `s3_cache_sync.ts:1649-1654`, `s3_cache_sync.ts:1667-1668`, `gcs_cache_sync.ts:1459-1464`, `gcs_cache_sync.ts:1478-1479` - **What's wrong**: `fetchForeignContent` validates `relPath` for path traversal (`../`, leading `/`, leading `\`), but `exportCatalog` and `pullForeignCatalogs` use the `namespace` parameter directly in key construction (`${namespace}/.catalog-export.json`) with zero validation. The same namespace parameter is also used unvalidated in `fetchForeignContent`'s key construction and in `bindNamespace` for `pullChanged`/`pushChanged`. - **Breaking example**: `exportCatalog("", rows)` writes to key `/.catalog-export.json`. `exportCatalog("ns/../../other", rows)` writes to `ns/../../other/.catalog-export.json`. While S3/GCS treats keys as opaque strings (no filesystem traversal), inconsistent validation across parameters in the same API surface is a code smell, and empty or malformed namespaces could cause confusing key layouts. - **Suggested fix**: Add a shared `assertValidNamespace(ns)` that rejects empty strings, strings containing `..` segments, leading/trailing slashes, and backslashes. Apply it in `bindNamespace`, `exportCatalog`, `pullForeignCatalogs`, and `fetchForeignContent`. ### Low 1. **Sidecar file is not namespace-scoped — stale fast-path data when cachePath is shared across namespaces** - **Files**: `s3_cache_sync.ts:466-469`, `gcs_cache_sync.ts:432-433` - **What's wrong**: The sync state sidecar (`.datastore-sync-state.json`) is stored at `join(cachePath, SYNC_STATE_FILE)` regardless of namespace. If two different namespaces share a cachePath (e.g., `~/.cache/swamp/datastores/my-store/`), the sidecar written by namespace A contains the ETag/generation from `ns-a/.datastore-index.json`. When namespace B starts and reads the sidecar, it compares against `ns-b/.datastore-index.json` — the ETags will almost certainly differ, so the fast path falls through to the slow path. The only risk is a vanishingly unlikely ETag collision causing the fast path to incorrectly return 0. - **In practice**: Self-healing. Worst case is one unnecessary slow-path execution per namespace switch. The `bindNamespace` check prevents within-instance switching. ### Verdict **PASS** — The namespace-scoped sync feature is well-structured with good test coverage (14 new tests across S3 and GCS). The `bindNamespace` immutability guard, `indexKey()` scoping, `discoverIndexFromBucket` prefix filtering, and `fetchForeignContent` path traversal check are all solid. The medium findings are edge cases in mixed solo↔namespace migration or abort semantics — none represent data loss or security risk in the primary use case (pure namespaced or pure solo mode). The code is backward compatible for solo mode as claimed.
stack72 deleted branch worktree-533 2026-06-03 13:28:11 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
swamp-club/swamp-extensions!26
No description provided.