From 29f609a73fe3439ce38aa31825668d9d0b39f161 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 15 Jul 2026 20:29:47 +0800 Subject: [PATCH 001/128] feat(deploy): add --backend cloudflare / --provision flags + early fork skeleton Wire the CLI entry surface for a self-hosted Cloudflare deploy backend: parse and validate --backend cloudflare and --provision in parseDeployArgs, thread them through cli.ts into RunDeployOptions, and add an early fork in runDeployInner to a stub runCloudflareDeploy that throws not-yet-implemented. --backend accepts only 'cloudflare'; --provision requires --backend cloudflare. The managed (default) deploy path is unchanged: the fork is the first statement of runDeployInner and returns early. Real orchestration lands in a later task. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/cli/args.ts | 18 +++++++++- packages/void/src/cli/cli.ts | 2 ++ packages/void/src/cli/deploy.ts | 24 +++++++++++++ packages/void/test/unit/deploy-args.test.ts | 37 +++++++++++++++++++++ 4 files changed, 80 insertions(+), 1 deletion(-) diff --git a/packages/void/src/cli/args.ts b/packages/void/src/cli/args.ts index 3508892e9..d96ca77da 100644 --- a/packages/void/src/cli/args.ts +++ b/packages/void/src/cli/args.ts @@ -6,6 +6,8 @@ export type DeployArgs = { spa?: boolean; skipBuild?: boolean; debug?: boolean; + backend?: 'cloudflare'; + provision?: boolean; }; export function parseDeployArgs(args: Array): DeployArgs { @@ -19,6 +21,8 @@ export function parseDeployArgs(args: Array): DeployArgs { spa: { type: 'boolean' }, 'skip-build': { type: 'boolean' }, debug: { type: 'boolean' }, + backend: { type: 'string' }, + provision: { type: 'boolean' }, }, allowPositionals: true, strict: true, @@ -62,5 +66,17 @@ export function parseDeployArgs(args: Array): DeployArgs { const skipBuild = parsed.values['skip-build'] === true ? true : undefined; const debug = parsed.values.debug === true ? true : undefined; - return { projectSlug, dir, spa, skipBuild, debug }; + const backendValue = + typeof parsed.values.backend === 'string' ? parsed.values.backend : undefined; + if (backendValue !== undefined && backendValue !== 'cloudflare') { + throw new Error("deploy: --backend must be 'cloudflare'."); + } + const backend = backendValue; + + const provision = parsed.values.provision === true ? true : undefined; + if (provision && backend !== 'cloudflare') { + throw new Error('deploy: --provision requires --backend cloudflare.'); + } + + return { projectSlug, dir, spa, skipBuild, debug, backend, provision }; } diff --git a/packages/void/src/cli/cli.ts b/packages/void/src/cli/cli.ts index 5583b43a3..519bad838 100644 --- a/packages/void/src/cli/cli.ts +++ b/packages/void/src/cli/cli.ts @@ -101,6 +101,8 @@ if (helpPath) { spa: deployArgs.spa, skipBuild: deployArgs.skipBuild, debug: deployArgs.debug, + backend: deployArgs.backend, + provision: deployArgs.provision, }).catch((err) => { log.error(err.message ?? err); process.exit(1); diff --git a/packages/void/src/cli/deploy.ts b/packages/void/src/cli/deploy.ts index 390853345..06d6cd911 100644 --- a/packages/void/src/cli/deploy.ts +++ b/packages/void/src/cli/deploy.ts @@ -136,6 +136,8 @@ export type RunDeployOptions = { spa?: boolean; skipBuild?: boolean; debug?: boolean; + backend?: 'cloudflare'; + provision?: boolean; }; type DeployDoneEvent = Extract; @@ -382,11 +384,33 @@ export function buildEnv(extra?: Record): NodeJS.ProcessEnv { return env; } +/** + * Deploy to the user's OWN Cloudflare account (no Void platform). + * + * Stub: the real orchestration (provisioning, config writing, wrangler deploy) + * lands in a later task. It intentionally reads its own config later, so it + * takes `root`/`options`/`cliLog` rather than any managed-platform state. + */ +async function runCloudflareDeploy( + _root: string, + _options: RunDeployOptions, + _cliLog: CliLogger, +): Promise { + throw new Error('void deploy --backend cloudflare is not yet implemented (Task 6)'); +} + async function runDeployInner( root: string, options: RunDeployOptions | undefined, cliLog: CliLogger, ): Promise { + // Fork to the self-hosted Cloudflare backend BEFORE any managed-platform work + // (auth token, PlatformClient, project resolution, config read, branch + // selection). Everything below stays byte-for-byte the managed `void` path. + if (options?.backend === 'cloudflare') { + return runCloudflareDeploy(root, options, cliLog); + } + let reauthenticated = false; while (true) { try { diff --git a/packages/void/test/unit/deploy-args.test.ts b/packages/void/test/unit/deploy-args.test.ts index 7cdf59399..1336dc8c8 100644 --- a/packages/void/test/unit/deploy-args.test.ts +++ b/packages/void/test/unit/deploy-args.test.ts @@ -92,4 +92,41 @@ describe('parseDeployArgs', () => { it('throws for unexpected positional arguments', () => { expect(() => parseDeployArgs(['my-app'])).toThrow("deploy: Unexpected argument 'my-app'."); }); + + it('parses --backend cloudflare', () => { + const result = parseDeployArgs(['--backend', 'cloudflare']); + expect(result.backend).toBe('cloudflare'); + expect(result.provision).toBeUndefined(); + }); + + it('parses --backend cloudflare --provision', () => { + const result = parseDeployArgs(['--backend', 'cloudflare', '--provision']); + expect(result.backend).toBe('cloudflare'); + expect(result.provision).toBe(true); + }); + + it('throws when --provision is used without --backend cloudflare', () => { + expect(() => parseDeployArgs(['--provision'])).toThrow( + 'deploy: --provision requires --backend cloudflare.', + ); + }); + + it('throws for an invalid --backend value', () => { + expect(() => parseDeployArgs(['--backend', 'node'])).toThrow( + "deploy: --backend must be 'cloudflare'.", + ); + }); + + it('parses --backend cloudflare together with --project and --skip-build', () => { + const result = parseDeployArgs(['--backend', 'cloudflare', '--project', 'foo', '--skip-build']); + expect(result.backend).toBe('cloudflare'); + expect(result.projectSlug).toBe('foo'); + expect(result.skipBuild).toBe(true); + }); + + it('leaves backend undefined when no --backend is given (managed path)', () => { + const result = parseDeployArgs([]); + expect(result.backend).toBeUndefined(); + expect(result.provision).toBeUndefined(); + }); }); From 0cc7f15882797526183f34d70621e5f8e7b5b8c0 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 15 Jul 2026 20:40:32 +0800 Subject: [PATCH 002/128] fix(cli): make --backend token-aware and fix bare --backend error message Two codex adversarial-review findings on the deploy --backend flag: - backend validation only checked parseArgs' collapsed final value, so `--backend node --backend cloudflare` succeeded despite node being invalid. Add a token-aware dup guard (mirroring --project) that rejects a repeated --backend before value validation. - bare `--backend` (no value) hit the generic missing-value catch branch hard-coded to the --project message. Extract the offending option name from the parseArgs error and emit a backend-specific message. Adds three regression tests in deploy-args.test.ts. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/cli/args.ts | 11 +++++++++++ packages/void/test/unit/deploy-args.test.ts | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/packages/void/src/cli/args.ts b/packages/void/src/cli/args.ts index d96ca77da..bfa0bb4db 100644 --- a/packages/void/src/cli/args.ts +++ b/packages/void/src/cli/args.ts @@ -32,6 +32,10 @@ export function parseDeployArgs(args: Array): DeployArgs { const message = error instanceof Error ? error.message : String(error); if (message.includes('argument missing') || message.includes('argument is ambiguous')) { + const optionMatch = message.match(/Option '(--[a-z-]+)/); + if (optionMatch?.[1] === '--backend') { + throw new Error("deploy: --backend requires a value ('cloudflare')."); + } throw new Error('deploy: --project requires a slug.'); } @@ -51,6 +55,13 @@ export function parseDeployArgs(args: Array): DeployArgs { throw new Error('deploy: --project can only be specified once.'); } + const backendTokens = tokens.filter( + (token) => token.kind === 'option' && token.name === 'backend', + ); + if (backendTokens.length > 1) { + throw new Error('deploy: --backend can only be specified once.'); + } + if (parsed.positionals.length > 0) { throw new Error(`deploy: Unexpected argument '${parsed.positionals[0]}'.`); } diff --git a/packages/void/test/unit/deploy-args.test.ts b/packages/void/test/unit/deploy-args.test.ts index 1336dc8c8..49821da3c 100644 --- a/packages/void/test/unit/deploy-args.test.ts +++ b/packages/void/test/unit/deploy-args.test.ts @@ -117,6 +117,24 @@ describe('parseDeployArgs', () => { ); }); + it('throws when --backend is repeated even if the final value is cloudflare', () => { + expect(() => parseDeployArgs(['--backend', 'node', '--backend', 'cloudflare'])).toThrow( + 'deploy: --backend can only be specified once.', + ); + }); + + it('throws for duplicate --backend cloudflare flags', () => { + expect(() => parseDeployArgs(['--backend', 'cloudflare', '--backend', 'cloudflare'])).toThrow( + 'deploy: --backend can only be specified once.', + ); + }); + + it('throws a backend-specific message when --backend has no value', () => { + expect(() => parseDeployArgs(['--backend'])).toThrow( + "deploy: --backend requires a value ('cloudflare').", + ); + }); + it('parses --backend cloudflare together with --project and --skip-build', () => { const result = parseDeployArgs(['--backend', 'cloudflare', '--project', 'foo', '--skip-build']); expect(result.backend).toBe('cloudflare'); From 59c2bfd01f338feceeecfd298388cc949582bdce Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 15 Jul 2026 20:51:13 +0800 Subject: [PATCH 003/128] feat(cli): add resolveWranglerCommand helper for local wrangler invocation Thin wrapper over resolveProjectCommand that resolves the project's local wrangler binary (node_modules/.bin/wrangler when present, bare `wrangler` otherwise) plus a subcommand, returning an execSync/spawnSync-ready command string. Prerequisite for the --backend cloudflare deploy path (provisioner + orchestrator). wrangler is not a special-cased bin like vite->vp, so delegating reuses the exact local-bin resolution without duplication. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/cli/preset.ts | 13 +++++++++ packages/void/test/unit/preset.test.ts | 39 +++++++++++++++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/packages/void/src/cli/preset.ts b/packages/void/src/cli/preset.ts index fec40fb6c..65b18f574 100644 --- a/packages/void/src/cli/preset.ts +++ b/packages/void/src/cli/preset.ts @@ -150,6 +150,19 @@ export function resolveProjectCommand(root: string, command: string): string { return args.length > 0 ? `${resolvedBin} ${args.join(' ')}` : resolvedBin; } +/** + * Resolve a command that runs the project's local `wrangler` binary with the + * given subcommand, preferring `node_modules/.bin/wrangler` when available. + * + * Thin wrapper over {@link resolveProjectCommand}: `wrangler` is not a + * special-cased bin (unlike `vite` → `vp`), so delegating reuses the exact + * local-bin resolution without duplicating it. Returns a shell command string + * suitable for `execSync`/`spawnSync` in the deploy path. + */ +export function resolveWranglerCommand(root: string, subcommand: string): string { + return resolveProjectCommand(root, `wrangler ${subcommand}`); +} + /** * Format a user-facing command string, rewriting `vite` to `vp` when the * project depends on Vite+ without expanding to an absolute local bin path. diff --git a/packages/void/test/unit/preset.test.ts b/packages/void/test/unit/preset.test.ts index 61006c2c3..d8fa1b659 100644 --- a/packages/void/test/unit/preset.test.ts +++ b/packages/void/test/unit/preset.test.ts @@ -1,7 +1,12 @@ import { mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { join, resolve } from 'pathe'; import { describe, expect, it, beforeEach, afterEach } from 'vite-plus/test'; -import { detectPreset, formatProjectCommand, resolveProjectCommand } from '../../src/cli/preset.ts'; +import { + detectPreset, + formatProjectCommand, + resolveProjectCommand, + resolveWranglerCommand, +} from '../../src/cli/preset.ts'; const TMP = join(import.meta.dirname, '.tmp-preset'); @@ -240,3 +245,35 @@ describe('project Vite commands', () => { expect(resolveProjectCommand(root, 'vite build')).toBe('vite build'); }); }); + +describe('resolveWranglerCommand', () => { + it('resolves to the local wrangler binary with the subcommand', () => { + const root = setupProject({ + pkg: { dependencies: { wrangler: '^4.0.0' } }, + files: { 'node_modules/.bin/wrangler': '' }, + }); + + expect(resolveWranglerCommand(root, 'deploy')).toBe( + `${join(root, 'node_modules', '.bin', 'wrangler')} deploy`, + ); + }); + + it('preserves multi-argument subcommands', () => { + const root = setupProject({ + pkg: { dependencies: { wrangler: '^4.0.0' } }, + files: { 'node_modules/.bin/wrangler': '' }, + }); + + expect(resolveWranglerCommand(root, 'd1 create foo --binding DB --update-config')).toBe( + `${join(root, 'node_modules', '.bin', 'wrangler')} d1 create foo --binding DB --update-config`, + ); + }); + + it('falls back to a bare wrangler command when no local binary exists', () => { + const root = setupProject({ + pkg: { dependencies: { wrangler: '^4.0.0' } }, + }); + + expect(resolveWranglerCommand(root, 'deploy')).toBe('wrangler deploy'); + }); +}); From 075152d9bc7dbe26bbfbc6e7dfb32cc5d8a56c4c Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 15 Jul 2026 21:08:12 +0800 Subject: [PATCH 004/128] fix(cli): shell-quote resolved executable path in resolveProjectCommand Project roots containing a space (or shell metacharacters) produced an unquoted absolute bin path that execSync's shell would word-split, e.g. `/tmp/my app/node_modules/.bin/wrangler deploy` tried to exec `/tmp/my`. Quote only the executable segment, only when it contains shell-significant characters, so space-free paths stay byte-identical. Platform-aware: single quotes on POSIX, double quotes on cmd.exe. Fixes resolveWranglerCommand and the pre-existing vite/framework build paths at the source. Add a filesystem-backed regression test with a root containing a space and a single quote, asserting the command is quoted and executes the right binary through a shell. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/cli/preset.ts | 28 +++++++++- packages/void/test/unit/preset.test.ts | 72 +++++++++++++++++++++++++- 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/packages/void/src/cli/preset.ts b/packages/void/src/cli/preset.ts index 65b18f574..1718fa2e2 100644 --- a/packages/void/src/cli/preset.ts +++ b/packages/void/src/cli/preset.ts @@ -146,10 +146,36 @@ export type StaticPreset = { */ export function resolveProjectCommand(root: string, command: string): string { const [bin, ...args] = command.trim().split(/\s+/); - const resolvedBin = localBin(root, resolveCommandBin(root, bin)); + const resolvedBin = shellQuoteExecutable(localBin(root, resolveCommandBin(root, bin))); return args.length > 0 ? `${resolvedBin} ${args.join(' ')}` : resolvedBin; } +/** + * Shell-quote a resolved executable path so it survives being concatenated into + * a command string. The deploy path runs these via `execSync`/`spawnSync`, + * which invoke a shell, so a bare absolute path containing a space (e.g. a + * project root like `/tmp/my app`) would be word-split by the shell and try to + * exec the wrong binary. Paths with no shell-significant characters are returned + * unchanged, so ordinary paths stay byte-identical and copy-pasteable; only the + * executable segment is quoted (the trailing subcommand args are known-safe + * tokens). Platform-aware: cmd.exe (Windows) uses double quotes, POSIX shells + * use single quotes. + */ +function shellQuoteExecutable(value: string): string { + if (process.platform === 'win32') { + // cmd.exe: wrap in double quotes when the path contains a space or a shell + // metacharacter. Windows filenames cannot contain `"`, so there is no + // embedded quote to escape. + return /[\s&|<>^()"]/.test(value) ? `"${value}"` : value; + } + // POSIX sh: characters in the safe set need no quoting; otherwise single-quote + // the path and escape any embedded single quote as '\''. + if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(value)) { + return value; + } + return `'${value.replace(/'/g, "'\\''")}'`; +} + /** * Resolve a command that runs the project's local `wrangler` binary with the * given subcommand, preferring `node_modules/.bin/wrangler` when available. diff --git a/packages/void/test/unit/preset.test.ts b/packages/void/test/unit/preset.test.ts index d8fa1b659..b912eda0c 100644 --- a/packages/void/test/unit/preset.test.ts +++ b/packages/void/test/unit/preset.test.ts @@ -1,4 +1,5 @@ -import { mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { execSync } from 'node:child_process'; +import { chmodSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { join, resolve } from 'pathe'; import { describe, expect, it, beforeEach, afterEach } from 'vite-plus/test'; import { @@ -277,3 +278,72 @@ describe('resolveWranglerCommand', () => { expect(resolveWranglerCommand(root, 'deploy')).toBe('wrangler deploy'); }); }); + +describe('resolveProjectCommand shell safety (project root with spaces)', () => { + // A dedicated root whose path contains a space + a single quote — both are + // shell-significant and must be quoted before the string reaches execSync. + const SPACED_TMP = join(import.meta.dirname, ".tmp preset o'space"); + + beforeEach(() => { + rmSync(SPACED_TMP, { recursive: true, force: true }); + }); + + afterEach(() => { + rmSync(SPACED_TMP, { recursive: true, force: true }); + }); + + function setupSpacedProject(binName: string): { root: string; binPath: string } { + mkdirSync(join(SPACED_TMP, 'node_modules', '.bin'), { recursive: true }); + writeFileSync(join(SPACED_TMP, 'package.json'), JSON.stringify({ dependencies: {} })); + const binPath = join(SPACED_TMP, 'node_modules', '.bin', binName); + // Executable that prints a sentinel + its args so we can confirm the shell + // launched THIS binary (not a truncated `/…/.tmp` prefix from word-splitting). + writeFileSync(binPath, `#!/bin/sh\necho "VOID_BIN_OK args=$*"\n`); + chmodSync(binPath, 0o755); + return { root: SPACED_TMP, binPath }; + } + + it('quotes the resolved executable path so the shell does not word-split it', () => { + const { root, binPath } = setupSpacedProject('wrangler'); + const cmd = resolveWranglerCommand(root, 'deploy'); + + // The bin path (which contains a space and a single quote) must be quoted + // as a single token, not left as a bare substring the shell would split. + expect(cmd).not.toBe(`${binPath} deploy`); + expect(cmd.endsWith(' deploy')).toBe(true); + if (process.platform === 'win32') { + expect(cmd).toBe(`"${binPath}" deploy`); + } else { + // POSIX single-quote wrap, embedded `'` escaped as '\''. + expect(cmd).toBe(`'${binPath.replace(/'/g, "'\\''")}' deploy`); + } + }); + + it.skipIf(process.platform === 'win32')( + 'executes the correct binary through a shell for a spaced root', + () => { + const { root } = setupSpacedProject('wrangler'); + const cmd = resolveWranglerCommand(root, 'deploy --dry-run'); + const out = execSync(cmd, { encoding: 'utf-8' }); + expect(out).toContain('VOID_BIN_OK'); + expect(out).toContain('args=deploy --dry-run'); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'shell-quotes the vite build path for a spaced root', + () => { + const { binPath } = setupSpacedProject('vp'); + // vite-plus present → `vite build` resolves to the local `vp` binary. + writeFileSync( + join(SPACED_TMP, 'package.json'), + JSON.stringify({ devDependencies: { 'vite-plus': '^1.0.0' } }), + ); + const cmd = resolveProjectCommand(SPACED_TMP, 'vite build'); + expect(cmd).toBe(`'${binPath.replace(/'/g, "'\\''")}' build`); + const out = execSync(cmd, { encoding: 'utf-8' }); + expect(out).toContain('VOID_BIN_OK'); + expect(out).toContain('args=build'); + }, + ); +}); From 5bca4378ea7a3d599c01ee9da31d5b9881271bd1 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 15 Jul 2026 21:32:01 +0800 Subject: [PATCH 005/128] feat(cli): comment-preserving root wrangler.jsonc writer for provisioning Add writeProvisionedWranglerConfig(root, patch) that fills the root-config gaps `wrangler create --update-config` won't write: top-level account_id (always overwrite), name / compatibility_date (only-if-absent), and migrations_dir on the D1 entry matched by binding (upsert). Uses jsonc-parser modify()+applyEdits() so comments and unrelated content survive (unlike the lossy JSON.stringify in syncWranglerBindings). All edits are idempotent upserts; indentation is sniffed from the existing file. Queues are intentionally omitted: mergeBindings (index.ts) already emits scanner-derived queues.producers/consumers into dist/ssr/wrangler.json unconditionally, and @cloudflare/vite-plugin merges the plugin config over the root config with defu (which concatenates arrays), so a root-declared queue entry would duplicate the scanner's in the deployed config. Queue provisioning stays `wrangler queues create` in the provisioner. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/cli/wrangler.ts | 138 ++++++++++++++++++- packages/void/test/unit/wrangler.test.ts | 167 +++++++++++++++++++++++ 2 files changed, 304 insertions(+), 1 deletion(-) diff --git a/packages/void/src/cli/wrangler.ts b/packages/void/src/cli/wrangler.ts index e87f6dc94..2adb0fcf4 100644 --- a/packages/void/src/cli/wrangler.ts +++ b/packages/void/src/cli/wrangler.ts @@ -1,6 +1,6 @@ import { createHash } from 'node:crypto'; import { readFileSync, writeFileSync, existsSync } from 'node:fs'; -import { parse, type ParseError } from 'jsonc-parser'; +import { applyEdits, modify, parse, type FormattingOptions, type ParseError } from 'jsonc-parser'; import { join } from 'pathe'; import type { VoidConfig } from '../config.ts'; import type { InferredBindings } from '../inference/walk.ts'; @@ -681,6 +681,142 @@ export function ensureWranglerNodejsAlsFlag(root: string): boolean { return true; } +/** + * Patch describing the root-config gaps that `wrangler create + * --update-config` does NOT fill. `writeProvisionedWranglerConfig` applies these + * to the project's root `wrangler.jsonc`/`wrangler.json`. + * + * NOTE — queues are intentionally NOT part of this patch. The build already + * emits scanner-derived `queues.producers`/`consumers` into + * `dist/ssr/wrangler.json` unconditionally (`mergeBindings` in `index.ts` sets + * `result.queues` whenever `queues.length > 0`, with no `resolved.queues` + * guard), and `@cloudflare/vite-plugin` merges the plugin's config over the root + * config with `defu`, whose default merger concatenates arrays. So a + * root-declared queue entry would DUPLICATE the scanner's in the deployed + * config. Queue provisioning is handled by `wrangler queues create` in the + * provisioner, never by editing the root config. + */ +export type ProvisionedWranglerPatch = { + /** Cloudflare account id → top-level `account_id` (always overwritten). */ + accountId?: string; + /** Worker name → top-level `name` (only written when absent). */ + name?: string; + /** Compatibility date → top-level `compatibility_date` (only written when absent). */ + compatibilityDate?: string; + /** + * Adds `migrations_dir` to the `d1_databases[]` entry whose `binding` matches. + * The D1 entry itself is created by `wrangler d1 create --update-config`; this + * only fills its `migrations_dir`. + */ + d1MigrationsDir?: { binding: string; dir: string }; +}; + +export type WriteProvisionedWranglerResult = { + /** Absolute path of the config file that was operated on. */ + configPath: string; + /** True when the file content changed (or a new file was created). */ + modified: boolean; + /** + * True when `d1MigrationsDir` was requested but no `d1_databases[]` entry with + * the given binding existed. The orchestrator controls ordering — the D1 + * entry is created by `wrangler d1 create --update-config` first, so a skip + * signals the writer ran before provisioning that binding. + */ + d1MigrationsDirSkipped: boolean; +}; + +/** + * Fill the parts of the root wrangler config that `wrangler create + * --update-config` leaves blank: top-level `account_id`, `name`, + * `compatibility_date`, and `migrations_dir` on a specific D1 entry. + * + * Comment-preserving: uses `jsonc-parser` `modify()` + `applyEdits()` so + * comments and unrelated content survive (unlike `syncWranglerBindings`, which + * round-trips through `JSON.stringify` and strips comments). Every edit is an + * idempotent upsert — running twice produces byte-identical output. + */ +export function writeProvisionedWranglerConfig( + root: string, + patch: ProvisionedWranglerPatch, +): WriteProvisionedWranglerResult { + // Resolve the root config file (same discovery idiom as the rest of this + // file: prefer wrangler.jsonc, fall back to wrangler.json, else create + // wrangler.jsonc). + let configPath: string | undefined; + for (const name of ['wrangler.jsonc', 'wrangler.json']) { + const p = join(root, name); + if (existsSync(p)) { + configPath = p; + break; + } + } + configPath ??= join(root, 'wrangler.jsonc'); + + const original = existsSync(configPath) ? readFileSync(configPath, 'utf-8') : '{}'; + let parsed: Record; + try { + parsed = parseJsonc(original); + } catch { + parsed = {}; + } + + const formattingOptions = detectFormattingOptions(original); + let text = original; + const set = (path: Array, value: unknown): void => { + text = applyEdits(text, modify(text, path, value, { formattingOptions })); + }; + + // account_id — always overwrite so a re-resolve can correct a stale value. + if (patch.accountId !== undefined) { + set(['account_id'], patch.accountId); + } + + // name / compatibility_date — only when absent (never clobber the user's). + if (patch.name !== undefined && parsed.name === undefined) { + set(['name'], patch.name); + } + if (patch.compatibilityDate !== undefined && parsed.compatibility_date === undefined) { + set(['compatibility_date'], patch.compatibilityDate); + } + + // migrations_dir — upsert onto the D1 entry matched by binding. Indices are + // stable because we only set a property (never insert/reorder array entries). + let d1MigrationsDirSkipped = false; + if (patch.d1MigrationsDir) { + const d1Entries = Array.isArray(parsed.d1_databases) + ? (parsed.d1_databases as Array<{ binding?: unknown }>) + : []; + const index = d1Entries.findIndex((entry) => entry.binding === patch.d1MigrationsDir!.binding); + if (index >= 0) { + set(['d1_databases', index, 'migrations_dir'], patch.d1MigrationsDir.dir); + } else { + d1MigrationsDirSkipped = true; + } + } + + const modified = text !== original; + if (modified) { + writeFileSync(configPath, text); + } + return { configPath, modified, d1MigrationsDirSkipped }; +} + +/** + * Detect indentation from an existing config so `jsonc-parser` formats inserted + * content to match. Falls back to two-space indentation (the repo default). + */ +function detectFormattingOptions(text: string): FormattingOptions { + const match = text.match(/^([ \t]+)\S/m); + if (match) { + const indent = match[1]; + if (indent.startsWith('\t')) { + return { tabSize: 1, insertSpaces: false }; + } + return { tabSize: indent.length, insertSpaces: true }; + } + return { tabSize: 2, insertSpaces: true }; +} + function parseJsonc(text: string): Record { const errors: Array = []; const result = parse(text, errors, { allowTrailingComma: true }); diff --git a/packages/void/test/unit/wrangler.test.ts b/packages/void/test/unit/wrangler.test.ts index 428ab54cc..15b524214 100644 --- a/packages/void/test/unit/wrangler.test.ts +++ b/packages/void/test/unit/wrangler.test.ts @@ -1,4 +1,5 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { parse as parseJsoncRaw } from 'jsonc-parser'; import { join } from 'pathe'; import { describe, it, expect, afterEach } from 'vite-plus/test'; import { @@ -10,6 +11,7 @@ import { readWranglerCompat, resolveLiveBindingConfig, syncWranglerBindings, + writeProvisionedWranglerConfig, } from '../../src/cli/wrangler.ts'; import type { VoidConfig } from '../../src/config.ts'; import type { InferredBindings } from '../../src/inference/walk.ts'; @@ -563,3 +565,168 @@ describe('syncWranglerBindings', () => { expect(syncWranglerBindings(tmpDir, noBindings)).toBe(false); }); }); + +describe('writeProvisionedWranglerConfig', () => { + const commentedFixture = `{ + // Void manages this file — comments below must survive provisioning. + "$schema": "node_modules/wrangler/config-schema.json", + "main": "src/index.ts", + /* App databases (real ids written by \`wrangler d1 create --update-config\`). */ + "d1_databases": [ + { "binding": "DB", "database_name": "app-db", "database_id": "abc-123" }, + { "binding": "ANALYTICS", "database_name": "an-db", "database_id": "def-456" } + ] +}`; + + function readRaw(filename: string): string { + return readFileSync(join(tmpDir, filename), 'utf-8'); + } + + function parseRaw(filename: string): Record { + return parseJsoncRaw(readRaw(filename)) as Record; + } + + it('preserves comments while filling account_id, name, compatibility_date, and migrations_dir', () => { + setup('wrangler.jsonc', commentedFixture); + const result = writeProvisionedWranglerConfig(tmpDir, { + accountId: 'acc-999', + name: 'my-app', + compatibilityDate: '2026-02-24', + d1MigrationsDir: { binding: 'DB', dir: 'db/migrations' }, + }); + + expect(result.modified).toBe(true); + expect(result.d1MigrationsDirSkipped).toBe(false); + expect(result.configPath).toBe(join(tmpDir, 'wrangler.jsonc')); + + const raw = readRaw('wrangler.jsonc'); + // Crux: comments must survive the write. + expect(raw).toContain('// Void manages this file'); + expect(raw).toContain('/* App databases'); + + const parsed = parseRaw('wrangler.jsonc'); + expect(parsed.account_id).toBe('acc-999'); + expect(parsed.name).toBe('my-app'); + expect(parsed.compatibility_date).toBe('2026-02-24'); + const d1 = parsed.d1_databases as Array>; + expect(d1[0]).toMatchObject({ binding: 'DB', migrations_dir: 'db/migrations' }); + // The other D1 entry is untouched. + expect(d1[1].migrations_dir).toBeUndefined(); + }); + + it('overwrites account_id but only sets name/compatibility_date when absent', () => { + setup( + 'wrangler.jsonc', + `{ + // user-owned fields + "name": "user-chosen-name", + "account_id": "stale-account", + "compatibility_date": "2025-01-01" +}`, + ); + const result = writeProvisionedWranglerConfig(tmpDir, { + accountId: 'fresh-account', + name: 'should-not-clobber', + compatibilityDate: '2026-02-24', + }); + + expect(result.modified).toBe(true); + const parsed = parseRaw('wrangler.jsonc'); + // account_id is always corrected on re-resolve. + expect(parsed.account_id).toBe('fresh-account'); + // name / compatibility_date are only-if-absent — user's values win. + expect(parsed.name).toBe('user-chosen-name'); + expect(parsed.compatibility_date).toBe('2025-01-01'); + expect(readRaw('wrangler.jsonc')).toContain('// user-owned fields'); + }); + + it('adds migrations_dir to the D1 entry matched by binding, leaving others untouched', () => { + setup('wrangler.jsonc', commentedFixture); + const result = writeProvisionedWranglerConfig(tmpDir, { + d1MigrationsDir: { binding: 'ANALYTICS', dir: 'db/analytics-migrations' }, + }); + + expect(result.d1MigrationsDirSkipped).toBe(false); + const d1 = parseRaw('wrangler.jsonc').d1_databases as Array>; + expect(d1[0].migrations_dir).toBeUndefined(); + expect(d1[1]).toMatchObject({ + binding: 'ANALYTICS', + migrations_dir: 'db/analytics-migrations', + }); + }); + + it('signals when no D1 entry matches the requested binding and leaves the file untouched', () => { + setup('wrangler.jsonc', commentedFixture); + const before = readRaw('wrangler.jsonc'); + const result = writeProvisionedWranglerConfig(tmpDir, { + d1MigrationsDir: { binding: 'MISSING', dir: 'db/migrations' }, + }); + + expect(result.d1MigrationsDirSkipped).toBe(true); + expect(result.modified).toBe(false); + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('is idempotent — running twice yields byte-identical output with no duplicate keys/entries', () => { + setup('wrangler.jsonc', commentedFixture); + const patch = { + accountId: 'acc-999', + name: 'my-app', + compatibilityDate: '2026-02-24', + d1MigrationsDir: { binding: 'DB', dir: 'db/migrations' }, + }; + + const first = writeProvisionedWranglerConfig(tmpDir, patch); + expect(first.modified).toBe(true); + const afterFirst = readRaw('wrangler.jsonc'); + + const second = writeProvisionedWranglerConfig(tmpDir, patch); + expect(second.modified).toBe(false); + expect(readRaw('wrangler.jsonc')).toBe(afterFirst); + + // No duplicated D1 entries or keys. + const d1 = parseRaw('wrangler.jsonc').d1_databases as Array>; + expect(d1).toHaveLength(2); + expect((afterFirst.match(/"account_id"/g) ?? []).length).toBe(1); + expect((afterFirst.match(/"migrations_dir"/g) ?? []).length).toBe(1); + }); + + it('operates on wrangler.json when wrangler.jsonc is absent', () => { + setup('wrangler.json', '{ "name": "json-app" }'); + const result = writeProvisionedWranglerConfig(tmpDir, { accountId: 'acc-1' }); + expect(result.configPath).toBe(join(tmpDir, 'wrangler.json')); + expect(parseRaw('wrangler.json').account_id).toBe('acc-1'); + }); + + it('prefers wrangler.jsonc over wrangler.json', () => { + setup('wrangler.jsonc', '{ "name": "jsonc-app" }'); + writeFileSync(join(tmpDir, 'wrangler.json'), '{ "name": "json-app" }'); + const result = writeProvisionedWranglerConfig(tmpDir, { accountId: 'acc-2' }); + expect(result.configPath).toBe(join(tmpDir, 'wrangler.jsonc')); + expect(parseRaw('wrangler.jsonc').account_id).toBe('acc-2'); + // The wrangler.json is left untouched. + expect(parseRaw('wrangler.json').account_id).toBeUndefined(); + }); + + it('creates wrangler.jsonc when no config exists and a patch is provided', () => { + rmSync(tmpDir, { recursive: true, force: true }); + mkdirSync(tmpDir, { recursive: true }); + const result = writeProvisionedWranglerConfig(tmpDir, { + accountId: 'acc-new', + name: 'brand-new', + }); + expect(result.configPath).toBe(join(tmpDir, 'wrangler.jsonc')); + expect(result.modified).toBe(true); + const parsed = parseRaw('wrangler.jsonc'); + expect(parsed.account_id).toBe('acc-new'); + expect(parsed.name).toBe('brand-new'); + }); + + it('preserves the fixture indentation style (tabs) for inserted content', () => { + setup('wrangler.jsonc', '{\n\t"name": "tabbed"\n}'); + writeProvisionedWranglerConfig(tmpDir, { accountId: 'acc-tab' }); + const raw = readRaw('wrangler.jsonc'); + expect(raw).toContain('\t"account_id": "acc-tab"'); + expect(parseRaw('wrangler.jsonc').account_id).toBe('acc-tab'); + }); +}); From 0c06307ccc8aca0b735d39a876b89e5c295675ec Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 15 Jul 2026 22:05:09 +0800 Subject: [PATCH 006/128] fix(cli): fail-closed BOM-aware wrangler writer + framework-queue upsert Finding 2: writeProvisionedWranglerConfig treated any jsonc parse failure as `{}`, so a UTF-8 BOM (kept by Node's utf-8 read) or malformed file clobbered existing name/compatibility_date (only-if-absent saw them absent) and skipped the matching D1 entry. Now strip + preserve a leading BOM (matching wrangler), and fail closed on a genuine parse error (throw, no write) instead of writing over the file. Finding 1: framework self-host (Class A forces queues=[] at index.ts:738; Class B/C return before mergeBindings) emits NO queue bindings from the build, yet framework mode DOES support production queues via the deploy wrapper. Add a comment-preserving, idempotent queues.producers/consumers upsert (producers keyed by binding, consumers by queue) so the Task 6 orchestrator can bind queues for framework builds only; native mode must not pass queues (build emits them, defu would duplicate). Corrected the per-app-class justification in code + report. 5 new tests (BOM round-trip + only-if-absent + D1 match; malformed throws untouched; queue upsert one-each; idempotent; append no-dup). 55 pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/cli/wrangler.ts | 153 ++++++++++++++++++++--- packages/void/test/unit/wrangler.test.ts | 132 +++++++++++++++++++ 2 files changed, 271 insertions(+), 14 deletions(-) diff --git a/packages/void/src/cli/wrangler.ts b/packages/void/src/cli/wrangler.ts index 2adb0fcf4..6854b51fc 100644 --- a/packages/void/src/cli/wrangler.ts +++ b/packages/void/src/cli/wrangler.ts @@ -13,6 +13,8 @@ import { } from '../sandbox/config.ts'; const COMPAT_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; +/** UTF-8 byte-order mark. Node's utf-8 read preserves a leading one. */ +const UTF8_BOM = ''; const NODEJS_ALS_FLAG = 'nodejs_als'; const NODEJS_COMPAT_FLAG = 'nodejs_compat'; const VOID_CONFIG_FILE = 'void.json'; @@ -681,20 +683,46 @@ export function ensureWranglerNodejsAlsFlag(root: string): boolean { return true; } +/** A wrangler `queues.producers[]` entry (written verbatim). */ +export type WranglerQueueProducer = { + queue: string; + binding: string; +}; + +/** + * A wrangler `queues.consumers[]` entry (written verbatim, snake_case wrangler + * keys). Mirrors exactly what Void's queue scanner / `mergeBindings` emit. + */ +export type WranglerQueueConsumer = { + queue: string; + max_batch_size?: number; + max_batch_timeout?: number; + max_retries?: number; + retry_delay?: number; +}; + /** * Patch describing the root-config gaps that `wrangler create * --update-config` does NOT fill. `writeProvisionedWranglerConfig` applies these * to the project's root `wrangler.jsonc`/`wrangler.json`. * - * NOTE — queues are intentionally NOT part of this patch. The build already - * emits scanner-derived `queues.producers`/`consumers` into - * `dist/ssr/wrangler.json` unconditionally (`mergeBindings` in `index.ts` sets - * `result.queues` whenever `queues.length > 0`, with no `resolved.queues` - * guard), and `@cloudflare/vite-plugin` merges the plugin's config over the root - * config with `defu`, whose default merger concatenates arrays. So a - * root-declared queue entry would DUPLICATE the scanner's in the deployed - * config. Queue provisioning is handled by `wrangler queues create` in the - * provisioner, never by editing the root config. + * Queues — per-app-class behavior (verified against `index.ts`/`deploy.ts`): + * + * - NATIVE Void mode (no meta-framework; `detectFramework` → undefined): + * `mergeBindings` (`index.ts`) emits scanner-derived `queues.producers`/ + * `consumers` into `dist/ssr/wrangler.json`, and `@cloudflare/vite-plugin` + * `defu`-merges the plugin config over the root config (arrays CONCATENATE). + * So the caller must NOT pass `queues` for a native app — it would DUPLICATE + * the scanner's entries. Leave `queues` undefined; the build binds them. + * + * - FRAMEWORK mode (Class A: `index.ts:738` forces `queues = []`; Class B/C: + * the plugin returns before `mergeBindings` runs): the build emits NO queue + * bindings, yet the queue feature IS supported in production — the deploy + * wrapper (`cli/wrapper.ts`) adds a `queue: handleQueue` consumer to the + * framework worker. On self-host there is no Void platform to bind the + * queues, and `wrangler queues create` has no `--update-config`, so the + * orchestrator (Task 6) passes the inferred producers/consumers here to + * bind them in the root config for framework self-host builds only. */ export type ProvisionedWranglerPatch = { /** Cloudflare account id → top-level `account_id` (always overwritten). */ @@ -709,6 +737,19 @@ export type ProvisionedWranglerPatch = { * only fills its `migrations_dir`. */ d1MigrationsDir?: { binding: string; dir: string }; + /** + * Queue producer/consumer bindings, upserted into `queues.producers[]` / + * `queues.consumers[]` idempotently (producers matched by `binding`, consumers + * by `queue` — never duplicated on re-run). + * + * FRAMEWORK self-host ONLY. See the per-app-class note on this type: a native + * Void app already emits scanner queues from the build, so passing `queues` + * for one would duplicate them via the `defu` array concat. + */ + queues?: { + producers?: Array; + consumers?: Array; + }; }; export type WriteProvisionedWranglerResult = { @@ -752,12 +793,25 @@ export function writeProvisionedWranglerConfig( } configPath ??= join(root, 'wrangler.jsonc'); - const original = existsSync(configPath) ? readFileSync(configPath, 'utf-8') : '{}'; + // Node's utf-8 read preserves a leading BOM; wrangler itself strips the BOM + // before parsing. Match that: strip it for parsing/editing, then re-add it on + // write so the file's bytes round-trip unchanged. + const rawContent = existsSync(configPath) ? readFileSync(configPath, 'utf-8') : '{}'; + const hasBom = rawContent.startsWith(UTF8_BOM); + const original = hasBom ? rawContent.slice(UTF8_BOM.length) : rawContent; + let parsed: Record; try { parsed = parseJsonc(original); - } catch { - parsed = {}; + } catch (error) { + // Fail closed. Never treat an unparseable config as `{}`: that would let the + // only-if-absent logic clobber an existing `name`/`compatibility_date`, miss + // real `d1_databases[]` entries, and rewrite a malformed file. Throw without + // writing so the caller surfaces the problem instead of corrupting the file. + throw new Error( + `deploy: cannot update "${configPath}" — it is not valid JSON/JSONC. ` + + `Fix the file and retry. (${(error as Error).message})`, + ); } const formattingOptions = detectFormattingOptions(original); @@ -794,9 +848,56 @@ export function writeProvisionedWranglerConfig( } } - const modified = text !== original; + // queues — idempotent upsert into `queues.producers[]` / `queues.consumers[]`. + // Producers key on `binding`, consumers on `queue`. Framework self-host only + // (see the note on ProvisionedWranglerPatch); a native app would duplicate the + // build's scanner queues. + const upsertQueueEntries = ( + section: 'producers' | 'consumers', + matchKey: 'binding' | 'queue', + entries: Array> | undefined, + ): void => { + if (!entries || entries.length === 0) { + return; + } + // Re-parse `text` (not the initial `parsed`) so earlier edits in this call + // are reflected; track appends locally to keep indices valid. + const currentQueues = parseJsonc(text).queues as Record | undefined; + const currentList = Array.isArray(currentQueues?.[section]) + ? [...(currentQueues[section] as Array>)] + : []; + for (const entry of entries) { + const index = currentList.findIndex( + (existing) => isRecord(existing) && existing[matchKey] === entry[matchKey], + ); + if (index >= 0) { + if (!jsonEqual(currentList[index], entry)) { + set(['queues', section, index], entry); + currentList[index] = entry; + } + } else { + // `isArrayInsertion` appends at `currentList.length`; it also creates the + // `queues` object and the section array when they don't exist yet. + text = applyEdits( + text, + modify(text, ['queues', section, currentList.length], entry, { + formattingOptions, + isArrayInsertion: true, + }), + ); + currentList.push(entry); + } + } + }; + if (patch.queues) { + upsertQueueEntries('producers', 'binding', patch.queues.producers); + upsertQueueEntries('consumers', 'queue', patch.queues.consumers); + } + + const finalText = hasBom ? `${UTF8_BOM}${text}` : text; + const modified = finalText !== rawContent; if (modified) { - writeFileSync(configPath, text); + writeFileSync(configPath, finalText); } return { configPath, modified, d1MigrationsDirSkipped }; } @@ -825,3 +926,27 @@ function parseJsonc(text: string): Record { } return result; } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** Order-insensitive structural equality — avoids rewriting an entry that only + * differs in key order (preserves the file's bytes/comments on that entry). */ +function jsonEqual(a: unknown, b: unknown): boolean { + return JSON.stringify(canonicalize(a)) === JSON.stringify(canonicalize(b)); +} + +function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(canonicalize); + } + if (isRecord(value)) { + return Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, canonicalize(value[key])]), + ); + } + return value; +} diff --git a/packages/void/test/unit/wrangler.test.ts b/packages/void/test/unit/wrangler.test.ts index 15b524214..52ca7b191 100644 --- a/packages/void/test/unit/wrangler.test.ts +++ b/packages/void/test/unit/wrangler.test.ts @@ -729,4 +729,136 @@ describe('writeProvisionedWranglerConfig', () => { expect(raw).toContain('\t"account_id": "acc-tab"'); expect(parseRaw('wrangler.jsonc').account_id).toBe('acc-tab'); }); + + it('handles a leading UTF-8 BOM: parses (never fail-open), respects only-if-absent, matches D1, keeps the BOM + comments', () => { + // Node's utf-8 read preserves the BOM; a fail-open-to-`{}` parser would see + // name/compatibility_date as absent and clobber them, and miss the D1 entry. + const bomFixture = + '' + + `{ + // BOM-prefixed config — must NOT be treated as empty. + "name": "user-name", + "compatibility_date": "2025-06-01", + "d1_databases": [ + { "binding": "DB", "database_name": "app", "database_id": "abc" } + ] +}`; + setup('wrangler.jsonc', bomFixture); + const result = writeProvisionedWranglerConfig(tmpDir, { + accountId: 'acc-bom', + name: 'should-not-clobber', + compatibilityDate: '2026-02-24', + d1MigrationsDir: { binding: 'DB', dir: 'db/migrations' }, + }); + + expect(result.modified).toBe(true); + // D1 matched despite the BOM (fail-open would have skipped it). + expect(result.d1MigrationsDirSkipped).toBe(false); + + const raw = readRaw('wrangler.jsonc'); + // BOM round-trips (wrangler-compatible) and comments survive. + expect(raw.charCodeAt(0)).toBe(0xfeff); + expect(raw).toContain('// BOM-prefixed config'); + + const parsed = parseJsoncRaw(raw.replace(/^/, '')) as Record; + // Only-if-absent respected — the user's values are NOT clobbered. + expect(parsed.name).toBe('user-name'); + expect(parsed.compatibility_date).toBe('2025-06-01'); + expect(parsed.account_id).toBe('acc-bom'); + const d1 = parsed.d1_databases as Array>; + expect(d1[0]).toMatchObject({ binding: 'DB', migrations_dir: 'db/migrations' }); + }); + + it('fails closed on a malformed config — throws without writing', () => { + const malformed = '{ "name": "x" "d1_databases": [] }'; // missing comma + setup('wrangler.jsonc', malformed); + expect(() => writeProvisionedWranglerConfig(tmpDir, { accountId: 'acc-x', name: 'y' })).toThrow( + /not valid JSON/, + ); + // The file is left byte-for-byte untouched. + expect(readRaw('wrangler.jsonc')).toBe(malformed); + }); + + it('upserts queue producers/consumers into a config with no queues — exactly one each, comments preserved', () => { + setup('wrangler.jsonc', commentedFixture); + const result = writeProvisionedWranglerConfig(tmpDir, { + queues: { + producers: [{ queue: 'jobs', binding: 'QUEUE_JOBS' }], + consumers: [{ queue: 'jobs', max_batch_size: 10 }], + }, + }); + + expect(result.modified).toBe(true); + expect(readRaw('wrangler.jsonc')).toContain('// Void manages this file'); + + const q = parseRaw('wrangler.jsonc').queues as { + producers: Array>; + consumers: Array>; + }; + expect(q.producers).toHaveLength(1); + expect(q.producers[0]).toEqual({ queue: 'jobs', binding: 'QUEUE_JOBS' }); + expect(q.consumers).toHaveLength(1); + expect(q.consumers[0]).toEqual({ queue: 'jobs', max_batch_size: 10 }); + }); + + it('queue upsert is idempotent — re-running yields exactly one producer + one consumer, byte-identical', () => { + setup('wrangler.jsonc', commentedFixture); + const patch = { + queues: { + producers: [{ queue: 'jobs', binding: 'QUEUE_JOBS' }], + consumers: [{ queue: 'jobs', max_batch_timeout: 5 }], + }, + }; + + const first = writeProvisionedWranglerConfig(tmpDir, patch); + expect(first.modified).toBe(true); + const afterFirst = readRaw('wrangler.jsonc'); + + const second = writeProvisionedWranglerConfig(tmpDir, patch); + expect(second.modified).toBe(false); + expect(readRaw('wrangler.jsonc')).toBe(afterFirst); + + const q = parseRaw('wrangler.jsonc').queues as { + producers: Array; + consumers: Array; + }; + expect(q.producers).toHaveLength(1); + expect(q.consumers).toHaveLength(1); + // No duplicated producer binding in the emitted text. + expect((afterFirst.match(/"binding": "QUEUE_JOBS"/g) ?? []).length).toBe(1); + }); + + it('appends a new queue producer/consumer without duplicating existing ones, leaving them untouched', () => { + setup( + 'wrangler.jsonc', + `{ + // existing queue + "queues": { + "producers": [{ "queue": "jobs", "binding": "QUEUE_JOBS" }], + "consumers": [{ "queue": "jobs" }] + } +}`, + ); + const result = writeProvisionedWranglerConfig(tmpDir, { + queues: { + producers: [ + { queue: 'jobs', binding: 'QUEUE_JOBS' }, // already present — must not duplicate + { queue: 'emails', binding: 'QUEUE_EMAILS' }, // new — appended + ], + consumers: [{ queue: 'emails', max_retries: 3 }], + }, + }); + + expect(result.modified).toBe(true); + expect(readRaw('wrangler.jsonc')).toContain('// existing queue'); + + const q = parseRaw('wrangler.jsonc').queues as { + producers: Array>; + consumers: Array>; + }; + expect(q.producers).toHaveLength(2); + expect(q.producers.map((p) => p.binding).sort()).toEqual(['QUEUE_EMAILS', 'QUEUE_JOBS']); + expect(q.consumers).toHaveLength(2); + expect(q.consumers.map((c) => c.queue).sort()).toEqual(['emails', 'jobs']); + }); }); From f967717c9dd9f9002082e8b96132901d3fbb42ea Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 15 Jul 2026 22:21:43 +0800 Subject: [PATCH 007/128] fix(cli): property-level queue upsert preserves user fields + comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The queue producer/consumer upsert in writeProvisionedWranglerConfig replaced the whole matching array element via a single jsonc-parser modify() at the element index whenever the existing entry differed from the narrow inferred patch. That dropped inline comments and any sibling fields the patch omits (producer delivery_delay; consumer dead_letter_queue, max_concurrency, …), silently altering production delivery/retry/concurrency/failure-routing and breaking the writer's comment/unrelated-content preservation contract. Upsert per-property instead: for an existing match, modify() only the specific keys the patch supplies (path into the element's properties), leaving unmentioned fields and comments intact; new entries still append. Skips undefined values so no field is deleted, and stays idempotent (a re-run finds each value equal and writes nothing). Adds a regression: a producer with delivery_delay + comment and a consumer with dead_letter_queue/max_concurrency + inline comment survive an upsert of the same queue, and re-running is byte-identical. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/cli/wrangler.ts | 20 ++++++- packages/void/test/unit/wrangler.test.ts | 71 ++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/packages/void/src/cli/wrangler.ts b/packages/void/src/cli/wrangler.ts index 6854b51fc..6d6963306 100644 --- a/packages/void/src/cli/wrangler.ts +++ b/packages/void/src/cli/wrangler.ts @@ -871,10 +871,24 @@ export function writeProvisionedWranglerConfig( (existing) => isRecord(existing) && existing[matchKey] === entry[matchKey], ); if (index >= 0) { - if (!jsonEqual(currentList[index], entry)) { - set(['queues', section, index], entry); - currentList[index] = entry; + // Update ONLY the properties this patch supplies — never replace the + // whole array element. A whole-object `modify()` at the element index + // would drop inline comments AND any sibling fields the narrower + // inferred patch omits (producer `delivery_delay`; consumer + // `dead_letter_queue`, `max_concurrency`, …), silently changing + // production delivery/retry/concurrency/failure-routing. Per-property + // edits preserve unmentioned fields and comments, and stay idempotent + // (a re-run finds each value already equal and writes nothing). + const existing = isRecord(currentList[index]) ? currentList[index] : {}; + for (const [key, value] of Object.entries(entry)) { + if (value === undefined) { + continue; + } + if (!jsonEqual(existing[key], value)) { + set(['queues', section, index, key], value); + } } + currentList[index] = { ...existing, ...entry }; } else { // `isArrayInsertion` appends at `currentList.length`; it also creates the // `queues` object and the section array when they don't exist yet. diff --git a/packages/void/test/unit/wrangler.test.ts b/packages/void/test/unit/wrangler.test.ts index 52ca7b191..9ffa6f964 100644 --- a/packages/void/test/unit/wrangler.test.ts +++ b/packages/void/test/unit/wrangler.test.ts @@ -861,4 +861,75 @@ describe('writeProvisionedWranglerConfig', () => { expect(q.consumers).toHaveLength(2); expect(q.consumers.map((c) => c.queue).sort()).toEqual(['emails', 'jobs']); }); + + it('updating a matching queue entry preserves user fields + inline comments (no whole-object clobber), and is idempotent', () => { + // A matching-but-differing entry must be patched property-by-property, NOT + // replaced wholesale: replacing the array element with the narrow inferred + // patch would drop the user's tuned fields (producer `delivery_delay`; + // consumer `dead_letter_queue`, `max_concurrency`) AND their comments, + // silently changing production delivery/retry/concurrency/failure-routing. + setup( + 'wrangler.jsonc', + `{ + // Void manages this file — comments below must survive queue upserts. + "queues": { + "producers": [ + // job producer — delivery_delay is user-tuned and must NOT be lost + { "queue": "jobs", "binding": "QUEUE_JOBS", "delivery_delay": 60 } + ], + "consumers": [ + { + "queue": "jobs", + "max_batch_size": 10, + "dead_letter_queue": "jobs-dlq", // route failures here — must survive + "max_concurrency": 3 + } + ] + } +}`, + ); + + const patch = { + queues: { + // Matches by binding; differs only because the fixture carries an extra + // `delivery_delay` the patch omits. This is exactly the clobber case. + producers: [{ queue: 'jobs', binding: 'QUEUE_JOBS' }], + // Matches by queue; changes max_batch_size 10 -> 25 and adds max_retries, + // while dead_letter_queue + max_concurrency must be preserved. + consumers: [{ queue: 'jobs', max_batch_size: 25, max_retries: 5 }], + }, + }; + + const first = writeProvisionedWranglerConfig(tmpDir, patch); + expect(first.modified).toBe(true); + + const raw = readRaw('wrangler.jsonc'); + // All comments — file-level, producer-level, and the inline one — survive. + expect(raw).toContain('// Void manages this file'); + expect(raw).toContain('// job producer — delivery_delay is user-tuned'); + expect(raw).toContain('// route failures here — must survive'); + + const q = parseRaw('wrangler.jsonc').queues as { + producers: Array>; + consumers: Array>; + }; + expect(q.producers).toHaveLength(1); + // Producer: user's delivery_delay preserved; queue/binding untouched. + expect(q.producers[0]).toEqual({ queue: 'jobs', binding: 'QUEUE_JOBS', delivery_delay: 60 }); + expect(q.consumers).toHaveLength(1); + // Consumer: supplied fields applied; user's DLQ + concurrency preserved. + expect(q.consumers[0]).toEqual({ + queue: 'jobs', + max_batch_size: 25, + max_retries: 5, + dead_letter_queue: 'jobs-dlq', + max_concurrency: 3, + }); + + // Idempotent — a second run writes nothing and is byte-identical. + const afterFirst = readRaw('wrangler.jsonc'); + const second = writeProvisionedWranglerConfig(tmpDir, patch); + expect(second.modified).toBe(false); + expect(readRaw('wrangler.jsonc')).toBe(afterFirst); + }); }); From 1ede2d4cb51db4f29bf62da6816dd64fc50e1002 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 15 Jul 2026 22:42:31 +0800 Subject: [PATCH 008/128] fix(cli): reject duplicate wrangler config members before upsert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jsonc-parser parse() resolves a duplicate key to the LAST member (what wrangler reads) while modify() edits the FIRST, so a property-level upsert against a duplicated key silently patched a shadowed member and left wrangler on the stale value — while a re-run looked byte-idempotent. Walk the AST and fail closed (throw without writing, naming the key + line/column) on any duplicate member, config-wide, matching the writer's malformed-config guard. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/cli/wrangler.ts | 81 +++++++++++++++++++++++- packages/void/test/unit/wrangler.test.ts | 57 +++++++++++++++++ 2 files changed, 137 insertions(+), 1 deletion(-) diff --git a/packages/void/src/cli/wrangler.ts b/packages/void/src/cli/wrangler.ts index 6d6963306..2453fa28d 100644 --- a/packages/void/src/cli/wrangler.ts +++ b/packages/void/src/cli/wrangler.ts @@ -1,6 +1,14 @@ import { createHash } from 'node:crypto'; import { readFileSync, writeFileSync, existsSync } from 'node:fs'; -import { applyEdits, modify, parse, type FormattingOptions, type ParseError } from 'jsonc-parser'; +import { + applyEdits, + modify, + parse, + parseTree, + type FormattingOptions, + type Node, + type ParseError, +} from 'jsonc-parser'; import { join } from 'pathe'; import type { VoidConfig } from '../config.ts'; import type { InferredBindings } from '../inference/walk.ts'; @@ -814,6 +822,20 @@ export function writeProvisionedWranglerConfig( ); } + // Fail closed on duplicate member names BEFORE any edit. jsonc-parser's + // `parse()` resolves a duplicate key to the LAST member (the value wrangler + // itself reads), but `modify()` edits the FIRST. So an upsert would compare + // against the effective (last) value, patch the shadowed (first) member, and + // leave wrangler reading the stale value — while a re-run finds the effective + // value still unchanged, re-patches the already-correct first member, and + // returns `modified: false`, looking byte-idempotent even though the requested + // production setting was NEVER applied. This is config-wide (a duplicate + // `account_id`, `compatibility_date`, a queue consumer's `max_batch_size`, + // etc.). Throwing without writing surfaces the problem instead of silently + // mis-editing (matches the malformed-config guard above). We do not "target + // the last member": that would leave a malformed, still-duplicated file. + assertNoDuplicateMembers(original, configPath); + const formattingOptions = detectFormattingOptions(original); let text = original; const set = (path: Array, value: unknown): void => { @@ -941,6 +963,63 @@ function parseJsonc(text: string): Record { return result; } +/** + * Throw (without writing) if any object in the config declares the same member + * name twice. See the call site for why a duplicate makes property-level upserts + * silently ineffective. Walks the jsonc AST so nested objects (queue consumers, + * D1 entries, …) are covered, not just the top level. + */ +function assertNoDuplicateMembers(text: string, configPath: string): void { + const root = parseTree(text, undefined, { allowTrailingComma: true }); + if (!root) { + return; + } + const walk = (node: Node): void => { + if (node.type === 'object' && node.children) { + const seen = new Set(); + for (const property of node.children) { + const keyNode = property.children?.[0]; + const key = keyNode?.value; + if (typeof key !== 'string') { + continue; + } + if (seen.has(key)) { + const { line, column } = offsetToLineColumn(text, keyNode!.offset); + throw new Error( + `deploy: cannot update "${configPath}" — duplicate key ${JSON.stringify(key)} ` + + `at line ${line}, column ${column}. Wrangler reads the last of two duplicate ` + + `members, but this writer edits the first, so the update would silently not ` + + `apply. Remove the duplicate and retry.`, + ); + } + seen.add(key); + } + } + if (node.children) { + for (const child of node.children) { + walk(child); + } + } + }; + walk(root); +} + +/** 1-based line/column for a byte offset — used in duplicate-key error messages. */ +function offsetToLineColumn(text: string, offset: number): { line: number; column: number } { + let line = 1; + let column = 1; + const limit = Math.min(offset, text.length); + for (let i = 0; i < limit; i++) { + if (text[i] === '\n') { + line += 1; + column = 1; + } else { + column += 1; + } + } + return { line, column }; +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } diff --git a/packages/void/test/unit/wrangler.test.ts b/packages/void/test/unit/wrangler.test.ts index 9ffa6f964..efc398b27 100644 --- a/packages/void/test/unit/wrangler.test.ts +++ b/packages/void/test/unit/wrangler.test.ts @@ -779,6 +779,63 @@ describe('writeProvisionedWranglerConfig', () => { expect(readRaw('wrangler.jsonc')).toBe(malformed); }); + it('fails closed on a queue consumer with a duplicated property — throws without writing', () => { + // jsonc-parser resolves the duplicate `max_batch_size` to the LAST member (20, + // what wrangler reads) but modify() edits the FIRST — so patching to 25 would + // leave wrangler on the stale 20 while the write looks byte-idempotent. Reject. + const dup = `{ + "queues": { + "consumers": [ + { + "queue": "jobs", + "max_batch_size": 10, + "max_batch_size": 20 + } + ] + } +}`; + setup('wrangler.jsonc', dup); + expect(() => + writeProvisionedWranglerConfig(tmpDir, { + queues: { consumers: [{ queue: 'jobs', max_batch_size: 25 }] }, + }), + ).toThrow(/duplicate key "max_batch_size"/); + // File left byte-for-byte untouched. + expect(readRaw('wrangler.jsonc')).toBe(dup); + }); + + it('fails closed on a top-level duplicated key (account_id twice) — throws without writing', () => { + const dup = `{ + "account_id": "aaa", + "account_id": "bbb" +}`; + setup('wrangler.jsonc', dup); + expect(() => writeProvisionedWranglerConfig(tmpDir, { accountId: 'ccc' })).toThrow( + /duplicate key "account_id"/, + ); + expect(readRaw('wrangler.jsonc')).toBe(dup); + }); + + it('does not fire the duplicate guard on a valid config — still upserts and stays idempotent', () => { + // Sanity: the guard must not break the normal path. A no-duplicate config + // upserts a queue consumer and a second run is byte-identical. + setup('wrangler.jsonc', commentedFixture); + const patch = { + accountId: 'acc-ok', + queues: { consumers: [{ queue: 'jobs', max_batch_size: 25 }] }, + }; + const first = writeProvisionedWranglerConfig(tmpDir, patch); + expect(first.modified).toBe(true); + const afterFirst = readRaw('wrangler.jsonc'); + + const q = parseRaw('wrangler.jsonc').queues as { consumers: Array> }; + expect(q.consumers).toEqual([{ queue: 'jobs', max_batch_size: 25 }]); + + const second = writeProvisionedWranglerConfig(tmpDir, patch); + expect(second.modified).toBe(false); + expect(readRaw('wrangler.jsonc')).toBe(afterFirst); + }); + it('upserts queue producers/consumers into a config with no queues — exactly one each, comments preserved', () => { setup('wrangler.jsonc', commentedFixture); const result = writeProvisionedWranglerConfig(tmpDir, { From 3160168d4c99b114412b24a2489a1e12b3e60136 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 15 Jul 2026 23:07:45 +0800 Subject: [PATCH 009/128] fix(cli): match wrangler queue producer upsert by queue name, not binding writeProvisionedWranglerConfig matched producers by `binding` while consumers matched by `queue`. Void derives a producer's binding deterministically from the queue name (QUEUE_; scan.ts + runtime/queues.ts), so the queue name is the stable identity. Matching on binding missed a user-renamed binding on the same queue and appended a duplicate producer, stranding sibling fields like delivery_delay on the now-unused entry that the runtime never dispatches through. - Match both producers and consumers by queue name (dropped the matchKey param). - Property-level upsert repoints binding in place, preserving delivery_delay and inline comments; idempotent re-runs are byte-identical. - Fail closed when the desired producer binding already binds a different queue. - Regression + guard tests added; all 61 wrangler tests + `vp run check` green. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/cli/wrangler.ts | 43 ++++++++++++--- packages/void/test/unit/wrangler.test.ts | 68 ++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 7 deletions(-) diff --git a/packages/void/src/cli/wrangler.ts b/packages/void/src/cli/wrangler.ts index 2453fa28d..0e95529ce 100644 --- a/packages/void/src/cli/wrangler.ts +++ b/packages/void/src/cli/wrangler.ts @@ -747,8 +747,12 @@ export type ProvisionedWranglerPatch = { d1MigrationsDir?: { binding: string; dir: string }; /** * Queue producer/consumer bindings, upserted into `queues.producers[]` / - * `queues.consumers[]` idempotently (producers matched by `binding`, consumers - * by `queue` — never duplicated on re-run). + * `queues.consumers[]` idempotently. BOTH sections match by queue NAME — never + * duplicated on re-run. Void derives a producer's binding deterministically + * from its queue name (`QUEUE_`; see `queues/scan.ts` + `runtime/queues.ts`), + * so the queue name — not the binding — is the stable identity. Matching a + * producer on `binding` would miss a user-renamed binding sitting on the same + * queue and silently append a second producer for it. * * FRAMEWORK self-host ONLY. See the per-app-class note on this type: a native * Void app already emits scanner queues from the build, so passing `queues` @@ -871,12 +875,15 @@ export function writeProvisionedWranglerConfig( } // queues — idempotent upsert into `queues.producers[]` / `queues.consumers[]`. - // Producers key on `binding`, consumers on `queue`. Framework self-host only + // BOTH sections key on the queue NAME. Void derives a producer's binding + // deterministically from its queue name (`QUEUE_`; see `queues/scan.ts` + + // `runtime/queues.ts`), so the queue name — not the binding — is the stable + // identity; matching a producer on `binding` would miss a user-renamed binding + // on the same queue and append a duplicate producer. Framework self-host only // (see the note on ProvisionedWranglerPatch); a native app would duplicate the // build's scanner queues. const upsertQueueEntries = ( section: 'producers' | 'consumers', - matchKey: 'binding' | 'queue', entries: Array> | undefined, ): void => { if (!entries || entries.length === 0) { @@ -889,8 +896,30 @@ export function writeProvisionedWranglerConfig( ? [...(currentQueues[section] as Array>)] : []; for (const entry of entries) { + // A producer `binding` must map to exactly ONE queue. If the desired + // binding already sits on a producer for a DIFFERENT queue, upserting here + // would leave two producers sharing one binding — a config wrangler + // rejects. Fail closed (before any write) with an actionable message + // rather than emitting the broken config. + if (section === 'producers' && typeof entry.binding === 'string') { + const conflict = currentList.find( + (existing) => + isRecord(existing) && + existing.binding === entry.binding && + existing.queue !== entry.queue, + ); + if (isRecord(conflict)) { + throw new Error( + `deploy: cannot update "${configPath}" — queue producer binding ` + + `${JSON.stringify(entry.binding)} is already bound to queue ` + + `${JSON.stringify(conflict.queue)}, but queue ${JSON.stringify(entry.queue)} also ` + + `requests it. A producer binding must map to exactly one queue. Rename or remove ` + + `the conflicting producer and retry.`, + ); + } + } const index = currentList.findIndex( - (existing) => isRecord(existing) && existing[matchKey] === entry[matchKey], + (existing) => isRecord(existing) && existing.queue === entry.queue, ); if (index >= 0) { // Update ONLY the properties this patch supplies — never replace the @@ -926,8 +955,8 @@ export function writeProvisionedWranglerConfig( } }; if (patch.queues) { - upsertQueueEntries('producers', 'binding', patch.queues.producers); - upsertQueueEntries('consumers', 'queue', patch.queues.consumers); + upsertQueueEntries('producers', patch.queues.producers); + upsertQueueEntries('consumers', patch.queues.consumers); } const finalText = hasBom ? `${UTF8_BOM}${text}` : text; diff --git a/packages/void/test/unit/wrangler.test.ts b/packages/void/test/unit/wrangler.test.ts index efc398b27..8d5971f1f 100644 --- a/packages/void/test/unit/wrangler.test.ts +++ b/packages/void/test/unit/wrangler.test.ts @@ -989,4 +989,72 @@ describe('writeProvisionedWranglerConfig', () => { expect(second.modified).toBe(false); expect(readRaw('wrangler.jsonc')).toBe(afterFirst); }); + + it('producer upsert matches by queue name — repoints a user-renamed binding on the same queue without duplicating, preserving delivery_delay + comments, idempotent', () => { + // Regression: the producer identity is the queue NAME, not the binding. + // A user who renamed the binding to CUSTOM on queue "jobs" must have it + // repointed to Void's deterministic QUEUE_JOBS binding IN PLACE — not have a + // second producer for "jobs" appended (which would strand their tuned + // `delivery_delay` on the now-unused CUSTOM producer, silently bypassed by + // the runtime that sends through QUEUE_JOBS). + setup( + 'wrangler.jsonc', + `{ + // Void manages this file — comments must survive queue upserts. + "queues": { + "producers": [ + // user renamed the binding but delivery_delay must NOT be lost + { "queue": "jobs", "binding": "CUSTOM", "delivery_delay": 60 } + ] + } +}`, + ); + + const patch = { + queues: { + producers: [{ queue: 'jobs', binding: 'QUEUE_JOBS' }], + }, + }; + + const first = writeProvisionedWranglerConfig(tmpDir, patch); + expect(first.modified).toBe(true); + + const raw = readRaw('wrangler.jsonc'); + expect(raw).toContain('// Void manages this file'); + expect(raw).toContain('// user renamed the binding but delivery_delay must NOT be lost'); + + const q = parseRaw('wrangler.jsonc').queues as { producers: Array> }; + // Exactly ONE producer for "jobs": binding repointed, delivery_delay retained. + expect(q.producers).toHaveLength(1); + expect(q.producers[0]).toEqual({ queue: 'jobs', binding: 'QUEUE_JOBS', delivery_delay: 60 }); + // No stranded CUSTOM producer left behind. + expect(raw).not.toContain('"CUSTOM"'); + + // Idempotent — a second run writes nothing and is byte-identical. + const afterFirst = readRaw('wrangler.jsonc'); + const second = writeProvisionedWranglerConfig(tmpDir, patch); + expect(second.modified).toBe(false); + expect(readRaw('wrangler.jsonc')).toBe(afterFirst); + }); + + it('fails closed when the desired producer binding is already bound to a DIFFERENT queue — throws without writing', () => { + setup( + 'wrangler.jsonc', + `{ + "queues": { + "producers": [{ "queue": "other", "binding": "QUEUE_JOBS" }] + } +}`, + ); + const before = readRaw('wrangler.jsonc'); + + expect(() => + writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'jobs', binding: 'QUEUE_JOBS' }] }, + }), + ).toThrow(/already bound to queue "other"/); + + // Fail closed — the file is untouched. + expect(readRaw('wrangler.jsonc')).toBe(before); + }); }); From 684137e4f9a370338c7f601c44b3a2ca46d0530d Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 16 Jul 2026 09:31:02 +0800 Subject: [PATCH 010/128] fix(cli): key queue producer upsert on binding + preserve trailing comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex round 5 on writeProvisionedWranglerConfig: Finding 1 — producer upsert keyed on queue name collapsed a legal {jobs,CUSTOM}+{jobs,QUEUE_JOBS} pair into two identical QUEUE_JOBS bindings (wrangler rejects duplicate binding names). Producers now key on their UNIQUE binding: a missing binding is appended (never hijacking a user's own same-queue producer), a binding already bound to a different queue fails closed. Consumers stay keyed on queue (one consumer/queue). Finding 2 — a formatting modify() reflowed the enclosing region and re-emitted a sibling's trailing inline comment onto the newly inserted member. New applyMemberEdit() strips the comment, runs the comment-free edit, then splices the comment back after the sibling's comma (located via parseTree, robust for arbitrary nesting). All insertions route through it; replacements and comment-free appends are byte-identical to before. Tests: producer-by-binding no-op + append regressions, and comment-trivia regressions for migrations_dir insert + queue append. 64 wrangler tests green; vp run check clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/cli/wrangler.ts | 214 ++++++++++++++++++----- packages/void/test/unit/wrangler.test.ts | 150 ++++++++++++++-- 2 files changed, 306 insertions(+), 58 deletions(-) diff --git a/packages/void/src/cli/wrangler.ts b/packages/void/src/cli/wrangler.ts index 0e95529ce..f35208c29 100644 --- a/packages/void/src/cli/wrangler.ts +++ b/packages/void/src/cli/wrangler.ts @@ -2,6 +2,7 @@ import { createHash } from 'node:crypto'; import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { applyEdits, + findNodeAtLocation, modify, parse, parseTree, @@ -747,12 +748,19 @@ export type ProvisionedWranglerPatch = { d1MigrationsDir?: { binding: string; dir: string }; /** * Queue producer/consumer bindings, upserted into `queues.producers[]` / - * `queues.consumers[]` idempotently. BOTH sections match by queue NAME — never - * duplicated on re-run. Void derives a producer's binding deterministically - * from its queue name (`QUEUE_`; see `queues/scan.ts` + `runtime/queues.ts`), - * so the queue name — not the binding — is the stable identity. Matching a - * producer on `binding` would miss a user-renamed binding sitting on the same - * queue and silently append a second producer for it. + * `queues.consumers[]` idempotently, keyed by each section's wrangler identity + * so re-runs never duplicate: + * + * - Producers are matched by `binding`. wrangler requires producer binding + * names to be UNIQUE across the worker (multiple producers may legally + * target one queue), so the binding — not the queue — is the identity. + * Void's binding is the deterministic `QUEUE_`. A missing binding is + * APPENDED (never rewriting a user's own same-queue producer); a binding + * already bound to a DIFFERENT queue fails closed (a binding must map to + * exactly one queue, else wrangler rejects the deploy). + * - Consumers are matched by `queue`. wrangler allows exactly one consumer + * per queue, so the queue name is the identity; matched entries are + * property-upserted (user-tuned fields preserved). * * FRAMEWORK self-host ONLY. See the per-app-class note on this type: a native * Void app already emits scanner queues from the build, so passing `queues` @@ -843,7 +851,7 @@ export function writeProvisionedWranglerConfig( const formattingOptions = detectFormattingOptions(original); let text = original; const set = (path: Array, value: unknown): void => { - text = applyEdits(text, modify(text, path, value, { formattingOptions })); + text = applyMemberEdit(text, path, value, { formattingOptions }); }; // account_id — always overwrite so a re-resolve can correct a stale value. @@ -875,13 +883,18 @@ export function writeProvisionedWranglerConfig( } // queues — idempotent upsert into `queues.producers[]` / `queues.consumers[]`. - // BOTH sections key on the queue NAME. Void derives a producer's binding - // deterministically from its queue name (`QUEUE_`; see `queues/scan.ts` + - // `runtime/queues.ts`), so the queue name — not the binding — is the stable - // identity; matching a producer on `binding` would miss a user-renamed binding - // on the same queue and append a duplicate producer. Framework self-host only - // (see the note on ProvisionedWranglerPatch); a native app would duplicate the - // build's scanner queues. + // Each section keys on its wrangler identity: + // - PRODUCERS key on `binding`. wrangler requires producer binding names to + // be unique across the worker (multiple producers may target one queue), + // so the binding — not the queue — is the identity. Void's binding is the + // deterministic `QUEUE_` (see `queues/scan.ts` + `runtime/queues.ts`). + // A missing binding is appended (a user's own same-queue producer with a + // different binding is left alone — Void must not hijack it); a binding + // already bound to a DIFFERENT queue fails closed. + // - CONSUMERS key on `queue`. wrangler allows exactly one consumer per queue, + // so the queue name is the identity. + // Framework self-host only (see the note on ProvisionedWranglerPatch); a native + // app would duplicate the build's scanner queues. const upsertQueueEntries = ( section: 'producers' | 'consumers', entries: Array> | undefined, @@ -896,31 +909,37 @@ export function writeProvisionedWranglerConfig( ? [...(currentQueues[section] as Array>)] : []; for (const entry of entries) { - // A producer `binding` must map to exactly ONE queue. If the desired - // binding already sits on a producer for a DIFFERENT queue, upserting here - // would leave two producers sharing one binding — a config wrangler - // rejects. Fail closed (before any write) with an actionable message - // rather than emitting the broken config. - if (section === 'producers' && typeof entry.binding === 'string') { - const conflict = currentList.find( - (existing) => - isRecord(existing) && - existing.binding === entry.binding && - existing.queue !== entry.queue, - ); - if (isRecord(conflict)) { + // Match on the section's identity: producers by `binding`, consumers by + // `queue`. Keying producers on the queue would rewrite the FIRST of several + // legal same-queue producers, so a `{queue,CUSTOM}` + `{queue,QUEUE_JOBS}` + // pair collapses into two identical `QUEUE_JOBS` bindings — a config + // wrangler rejects. + const index = + section === 'producers' + ? currentList.findIndex( + (existing) => isRecord(existing) && existing.binding === entry.binding, + ) + : currentList.findIndex( + (existing) => isRecord(existing) && existing.queue === entry.queue, + ); + + // A producer `binding` must map to exactly ONE queue. If this binding is + // already bound to a DIFFERENT queue, upserting would leave two producers + // sharing one binding — a config wrangler rejects. Fail closed (before any + // write) with an actionable message rather than emitting the broken config. + if (section === 'producers' && index >= 0) { + const existing = currentList[index]; + if (isRecord(existing) && existing.queue !== entry.queue) { throw new Error( `deploy: cannot update "${configPath}" — queue producer binding ` + `${JSON.stringify(entry.binding)} is already bound to queue ` + - `${JSON.stringify(conflict.queue)}, but queue ${JSON.stringify(entry.queue)} also ` + + `${JSON.stringify(existing.queue)}, but queue ${JSON.stringify(entry.queue)} also ` + `requests it. A producer binding must map to exactly one queue. Rename or remove ` + `the conflicting producer and retry.`, ); } } - const index = currentList.findIndex( - (existing) => isRecord(existing) && existing.queue === entry.queue, - ); + if (index >= 0) { // Update ONLY the properties this patch supplies — never replace the // whole array element. A whole-object `modify()` at the element index @@ -929,7 +948,9 @@ export function writeProvisionedWranglerConfig( // `dead_letter_queue`, `max_concurrency`, …), silently changing // production delivery/retry/concurrency/failure-routing. Per-property // edits preserve unmentioned fields and comments, and stay idempotent - // (a re-run finds each value already equal and writes nothing). + // (a re-run finds each value already equal and writes nothing). For a + // producer matched by binding the queue already equals (a mismatch threw + // above), so this loop is a no-op that preserves the user's entry. const existing = isRecord(currentList[index]) ? currentList[index] : {}; for (const [key, value] of Object.entries(entry)) { if (value === undefined) { @@ -941,15 +962,14 @@ export function writeProvisionedWranglerConfig( } currentList[index] = { ...existing, ...entry }; } else { - // `isArrayInsertion` appends at `currentList.length`; it also creates the - // `queues` object and the section array when they don't exist yet. - text = applyEdits( - text, - modify(text, ['queues', section, currentList.length], entry, { - formattingOptions, - isArrayInsertion: true, - }), - ); + // Append at `currentList.length`; `applyMemberEdit` also creates the + // `queues` object and the section array when they don't exist yet, and + // keeps a trailing inline comment attached to the preceding entry it + // documented (see the comment-relocation logic there). + text = applyMemberEdit(text, ['queues', section, currentList.length], entry, { + formattingOptions, + isArrayInsertion: true, + }); currentList.push(entry); } } @@ -992,6 +1012,118 @@ function parseJsonc(text: string): Record { return result; } +/** + * `modify()` + `applyEdits()`, but when the edit APPENDS a new member after a + * sibling that carries a trailing inline comment, keep that comment attached to + * the sibling it documented. + * + * jsonc-parser's formatting `modify()` reflows the whole enclosing member/entry + * region and re-emits a trailing inline comment at the END of that region — so + * inserting `migrations_dir` after `"database_id": "id" // production db` (or + * appending a queue entry after `{ … } // jobs producer`) silently reassociates + * the comment onto the NEWLY inserted member. That violates the writer's + * comment-preservation contract. Here we detect that exact case, run the + * (comment-free) formatting edit on a stripped copy, then splice the comment + * back right after the sibling's separating comma so it stays put. + * + * Every other edit (value replacement, append after a comment-free sibling, + * first insertion into a missing container) falls through to the plain path, so + * behavior is byte-identical to before outside the trailing-comment case. + */ +function applyMemberEdit( + text: string, + path: Array, + value: unknown, + options: { formattingOptions: FormattingOptions; isArrayInsertion?: boolean }, +): string { + const plain = (): string => applyEdits(text, modify(text, path, value, options)); + + const root = parseTree(text, undefined, { allowTrailingComma: true }); + if (!root) { + return plain(); + } + + // Only an APPEND can split a sibling from its trailing comment. A value + // replacement (existing object key, not an array insertion) reformats just + // that member and keeps its own comment — nothing to relocate. + if (!options.isArrayInsertion && findNodeAtLocation(root, path) !== undefined) { + return plain(); + } + + const container = findNodeAtLocation(root, path.slice(0, -1)); + const members = container?.children; + if (!container || !members || members.length === 0) { + return plain(); + } + + // jsonc-parser appends both new object properties and new array elements after + // the container's current last member, so that member is the preceding sibling. + const last = members[members.length - 1]; + const trailing = readTrailingComment(text, last.offset + last.length); + if (!trailing) { + return plain(); + } + + // Strip the comment, run the now-comment-free formatting edit, then re-attach + // the comment after the sibling's separating comma in the rewritten text. + const stripped = text.slice(0, last.offset + last.length) + text.slice(trailing.end); + const rewritten = applyEdits(stripped, modify(stripped, path, value, options)); + + const newRoot = parseTree(rewritten, undefined, { allowTrailingComma: true }); + const newContainer = newRoot ? findNodeAtLocation(newRoot, path.slice(0, -1)) : undefined; + const newMembers = newContainer?.children; + if (!newRoot || !newContainer || !newMembers || newMembers.length < 2) { + // We just appended, so this should not happen; fall back rather than corrupt. + return plain(); + } + // The appended member is last; its predecessor is the sibling we relocate onto. + const sibling = newMembers[newMembers.length - 2]; + const commaPos = rewritten.indexOf(',', sibling.offset + sibling.length); + if (commaPos === -1) { + return plain(); + } + return `${rewritten.slice(0, commaPos + 1)} ${trailing.text}${rewritten.slice(commaPos + 1)}`; +} + +/** + * If a trailing inline comment sits on the same line right after `offset` (only + * whitespace between), return its text and end offset. A `//` line comment or a + * single-line `/* *\/` block comment counts; a newline, comma, or closing + * bracket first means there is no trailing comment (returns undefined). + */ +function readTrailingComment( + text: string, + offset: number, +): { text: string; end: number } | undefined { + let i = offset; + while (i < text.length && (text[i] === ' ' || text[i] === '\t')) { + i++; + } + if (text[i] === '/' && text[i + 1] === '/') { + let end = i; + while (end < text.length && text[end] !== '\n') { + end++; + } + if (end > i && text[end - 1] === '\r') { + end--; + } + return { text: text.slice(i, end), end }; + } + if (text[i] === '/' && text[i + 1] === '*') { + const close = text.indexOf('*/', i + 2); + if (close === -1) { + return undefined; + } + const end = close + 2; + // Only a single-line block comment is safe to relocate onto one line. + if (text.slice(i, end).includes('\n')) { + return undefined; + } + return { text: text.slice(i, end), end }; + } + return undefined; +} + /** * Throw (without writing) if any object in the config declares the same member * name twice. See the call site for why a duplicate makes property-level upserts diff --git a/packages/void/test/unit/wrangler.test.ts b/packages/void/test/unit/wrangler.test.ts index 8d5971f1f..06b764883 100644 --- a/packages/void/test/unit/wrangler.test.ts +++ b/packages/void/test/unit/wrangler.test.ts @@ -990,20 +990,55 @@ describe('writeProvisionedWranglerConfig', () => { expect(readRaw('wrangler.jsonc')).toBe(afterFirst); }); - it('producer upsert matches by queue name — repoints a user-renamed binding on the same queue without duplicating, preserving delivery_delay + comments, idempotent', () => { - // Regression: the producer identity is the queue NAME, not the binding. - // A user who renamed the binding to CUSTOM on queue "jobs" must have it - // repointed to Void's deterministic QUEUE_JOBS binding IN PLACE — not have a - // second producer for "jobs" appended (which would strand their tuned - // `delivery_delay` on the now-unused CUSTOM producer, silently bypassed by - // the runtime that sends through QUEUE_JOBS). + it('producer upsert keys on binding — a matching QUEUE_JOBS beside a user CUSTOM producer on the same queue is a no-op (no duplicate binding)', () => { + // Producers are keyed by their UNIQUE `binding`, not the queue: wrangler + // allows multiple producers per queue but rejects duplicate binding names. + // Given a legal `{jobs,CUSTOM}` + `{jobs,QUEUE_JOBS}` pair, desiring + // `{jobs,QUEUE_JOBS}` must leave both untouched. A by-queue upsert would + // rewrite the FIRST match (CUSTOM → QUEUE_JOBS), minting two identical + // QUEUE_JOBS bindings that wrangler rejects. + setup( + 'wrangler.jsonc', + `{ + "queues": { + "producers": [ + { "queue": "jobs", "binding": "CUSTOM" }, + { "queue": "jobs", "binding": "QUEUE_JOBS" } + ] + } +}`, + ); + const before = readRaw('wrangler.jsonc'); + + const result = writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'jobs', binding: 'QUEUE_JOBS' }] }, + }); + + // Already present under its binding — nothing to do, byte-identical. + expect(result.modified).toBe(false); + expect(readRaw('wrangler.jsonc')).toBe(before); + + const q = parseRaw('wrangler.jsonc').queues as { producers: Array> }; + expect(q.producers).toEqual([ + { queue: 'jobs', binding: 'CUSTOM' }, + { queue: 'jobs', binding: 'QUEUE_JOBS' }, + ]); + // Exactly one QUEUE_JOBS binding — no duplicate minted. + expect((before.match(/"QUEUE_JOBS"/g) ?? []).length).toBe(1); + }); + + it('producer upsert appends QUEUE_JOBS beside a user CUSTOM producer on the same queue — never hijacks the user producer', () => { + // A user renamed their producer binding to CUSTOM on queue "jobs". Void's + // deterministic QUEUE_JOBS binding is absent, so it is APPENDED as its own + // producer; the user's CUSTOM producer (and its tuned delivery_delay) is left + // exactly as-is. Void must not rewrite a binding it does not own. setup( 'wrangler.jsonc', `{ // Void manages this file — comments must survive queue upserts. "queues": { "producers": [ - // user renamed the binding but delivery_delay must NOT be lost + // user's own producer — must be left untouched { "queue": "jobs", "binding": "CUSTOM", "delivery_delay": 60 } ] } @@ -1011,9 +1046,7 @@ describe('writeProvisionedWranglerConfig', () => { ); const patch = { - queues: { - producers: [{ queue: 'jobs', binding: 'QUEUE_JOBS' }], - }, + queues: { producers: [{ queue: 'jobs', binding: 'QUEUE_JOBS' }] }, }; const first = writeProvisionedWranglerConfig(tmpDir, patch); @@ -1021,14 +1054,13 @@ describe('writeProvisionedWranglerConfig', () => { const raw = readRaw('wrangler.jsonc'); expect(raw).toContain('// Void manages this file'); - expect(raw).toContain('// user renamed the binding but delivery_delay must NOT be lost'); + expect(raw).toContain("// user's own producer — must be left untouched"); const q = parseRaw('wrangler.jsonc').queues as { producers: Array> }; - // Exactly ONE producer for "jobs": binding repointed, delivery_delay retained. - expect(q.producers).toHaveLength(1); - expect(q.producers[0]).toEqual({ queue: 'jobs', binding: 'QUEUE_JOBS', delivery_delay: 60 }); - // No stranded CUSTOM producer left behind. - expect(raw).not.toContain('"CUSTOM"'); + // Two producers now: the user's CUSTOM (untouched) + Void's appended QUEUE_JOBS. + expect(q.producers).toHaveLength(2); + expect(q.producers[0]).toEqual({ queue: 'jobs', binding: 'CUSTOM', delivery_delay: 60 }); + expect(q.producers[1]).toEqual({ queue: 'jobs', binding: 'QUEUE_JOBS' }); // Idempotent — a second run writes nothing and is byte-identical. const afterFirst = readRaw('wrangler.jsonc'); @@ -1057,4 +1089,88 @@ describe('writeProvisionedWranglerConfig', () => { // Fail closed — the file is untouched. expect(readRaw('wrangler.jsonc')).toBe(before); }); + + it('inserting migrations_dir keeps a trailing inline comment on the property it documented (no reassociation)', () => { + // The D1 entry's LAST property carries a trailing inline comment. Naively + // inserting migrations_dir via a formatting modify() reflows the region and + // re-emits the comment at the end — silently moving `// production database` + // onto migrations_dir. The comment must stay with database_id. + setup( + 'wrangler.jsonc', + `{ + "d1_databases": [ + { + "binding": "DB", + "database_name": "app", + "database_id": "prod-id" // production database + } + ] +}`, + ); + + const result = writeProvisionedWranglerConfig(tmpDir, { + d1MigrationsDir: { binding: 'DB', dir: 'db/migrations' }, + }); + expect(result.modified).toBe(true); + expect(result.d1MigrationsDirSkipped).toBe(false); + + const raw = readRaw('wrangler.jsonc'); + // Comment stays attached to database_id (comma before it), NOT migrations_dir. + expect(raw).toContain('"database_id": "prod-id", // production database'); + expect(raw).not.toMatch(/migrations_dir[^\n]*production database/); + + const d1 = parseRaw('wrangler.jsonc').d1_databases as Array>; + expect(d1[0]).toMatchObject({ binding: 'DB', migrations_dir: 'db/migrations' }); + + // Idempotent — a second run writes nothing and is byte-identical. + const afterFirst = readRaw('wrangler.jsonc'); + const second = writeProvisionedWranglerConfig(tmpDir, { + d1MigrationsDir: { binding: 'DB', dir: 'db/migrations' }, + }); + expect(second.modified).toBe(false); + expect(readRaw('wrangler.jsonc')).toBe(afterFirst); + }); + + it('appending a queue entry keeps a trailing inline comment on the entry it documented (no reassociation)', () => { + // The producers array's LAST entry carries a trailing inline comment. + // Appending a new entry must not reflow `// jobs producer` onto the appended + // emails entry — it must stay with the jobs entry. + setup( + 'wrangler.jsonc', + `{ + "queues": { + "producers": [ + { "queue": "jobs", "binding": "QUEUE_JOBS" } // jobs producer + ] + } +}`, + ); + + const result = writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'emails', binding: 'QUEUE_EMAILS' }] }, + }); + expect(result.modified).toBe(true); + + const raw = readRaw('wrangler.jsonc'); + // The comment stays with the jobs entry (before the appended emails entry), + // never reassociated onto QUEUE_EMAILS. + expect(raw).toContain('}, // jobs producer'); + expect(raw.indexOf('// jobs producer')).toBeGreaterThan(raw.indexOf('QUEUE_JOBS')); + expect(raw.indexOf('// jobs producer')).toBeLessThan(raw.indexOf('QUEUE_EMAILS')); + expect(raw).not.toMatch(/QUEUE_EMAILS[\s\S]*jobs producer/); + + const q = parseRaw('wrangler.jsonc').queues as { producers: Array> }; + expect(q.producers).toEqual([ + { queue: 'jobs', binding: 'QUEUE_JOBS' }, + { queue: 'emails', binding: 'QUEUE_EMAILS' }, + ]); + + // Idempotent — a second run writes nothing and is byte-identical. + const afterFirst = readRaw('wrangler.jsonc'); + const second = writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'emails', binding: 'QUEUE_EMAILS' }] }, + }); + expect(second.modified).toBe(false); + expect(readRaw('wrangler.jsonc')).toBe(afterFirst); + }); }); From 858b0a1f0d00a785968d5447cb1d7e2bf9b67514 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 16 Jul 2026 09:57:15 +0800 Subject: [PATCH 011/128] fix(cli): keep trailing comments put across a separator comma + missing container Round-6 comment-relocation edges in the wrangler writer: - readTrailingComment now tolerates an optional separator/trailing comma between a sibling's value and its inline comment, so `"database_id": "x", // c` no longer reassociates the comment onto an inserted migrations_dir. - applyMemberEdit resolves the actual receiving container (walks up to the deepest existing ancestor when the leaf container is created fresh), so adding a `producers` section next to a trailing-commented `consumers` keeps the comment on consumers instead of the new producers array. Handles `//` and single-line `/* */` comments. A multi-line block comment used as a trailing comment remains a documented, test-pinned limitation (text preserved, association best-effort). No id-ownership / queue-identity / idempotency / fail-closed behavior touched. 69 wrangler tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/cli/wrangler.ts | 48 +++++- packages/void/test/unit/wrangler.test.ts | 191 +++++++++++++++++++++++ 2 files changed, 231 insertions(+), 8 deletions(-) diff --git a/packages/void/src/cli/wrangler.ts b/packages/void/src/cli/wrangler.ts index f35208c29..618895589 100644 --- a/packages/void/src/cli/wrangler.ts +++ b/packages/void/src/cli/wrangler.ts @@ -1050,7 +1050,21 @@ function applyMemberEdit( return plain(); } - const container = findNodeAtLocation(root, path.slice(0, -1)); + // Find the container `modify()` will append the new member to. Usually the + // leaf container (`path` minus its final key/index). But when that container + // does not exist yet, `modify()` creates the first missing segment as a NEW + // member of the deepest ANCESTOR that DOES exist — so that ancestor is the + // receiver, and its current last member is the preceding sibling whose + // trailing comment must stay put (e.g. adding `queues.producers` next to a + // `queues.consumers` that carries a `// …` trailing comment). Anchoring the + // relocation on the real receiving container keeps the comment on the sibling + // in both cases. + let receivingPath = path.slice(0, -1); + let container = findNodeAtLocation(root, receivingPath); + while (!container && receivingPath.length > 0) { + receivingPath = receivingPath.slice(0, -1); + container = findNodeAtLocation(root, receivingPath); + } const members = container?.children; if (!container || !members || members.length === 0) { return plain(); @@ -1064,13 +1078,15 @@ function applyMemberEdit( return plain(); } - // Strip the comment, run the now-comment-free formatting edit, then re-attach - // the comment after the sibling's separating comma in the rewritten text. + // Strip the comment (and the optional separator comma `readTrailingComment` + // skipped to reach it), run the now-comment-free formatting edit, then + // re-attach the comment after the sibling's separating comma in the rewritten + // text. const stripped = text.slice(0, last.offset + last.length) + text.slice(trailing.end); const rewritten = applyEdits(stripped, modify(stripped, path, value, options)); const newRoot = parseTree(rewritten, undefined, { allowTrailingComma: true }); - const newContainer = newRoot ? findNodeAtLocation(newRoot, path.slice(0, -1)) : undefined; + const newContainer = newRoot ? findNodeAtLocation(newRoot, receivingPath) : undefined; const newMembers = newContainer?.children; if (!newRoot || !newContainer || !newMembers || newMembers.length < 2) { // We just appended, so this should not happen; fall back rather than corrupt. @@ -1086,10 +1102,16 @@ function applyMemberEdit( } /** - * If a trailing inline comment sits on the same line right after `offset` (only - * whitespace between), return its text and end offset. A `//` line comment or a - * single-line `/* *\/` block comment counts; a newline, comma, or closing - * bracket first means there is no trailing comment (returns undefined). + * If a trailing inline comment sits on the same line right after `offset`, + * return its text and end offset. Only whitespace and an OPTIONAL single + * separator comma may sit between the sibling's value and the comment — so both + * `"id": "x" // c` and `"id": "x", // c` (a JSONC trailing comma, or the + * separator once a sibling follows) are recognized. A `//` line comment or a + * single-line `/* *\/` block comment counts; a newline or closing bracket first + * means there is no trailing comment (returns undefined). `offset` points just + * past the sibling's value, and the sibling is always its container's LAST + * member, so any comma here is a trailing/separator comma — never a boundary to + * an existing following member. */ function readTrailingComment( text: string, @@ -1099,6 +1121,16 @@ function readTrailingComment( while (i < text.length && (text[i] === ' ' || text[i] === '\t')) { i++; } + // Tolerate one optional separator/trailing comma between the value and its + // inline comment. `stripped` (at the call site) removes everything from + // `offset` through the comment's end, so the skipped comma is discarded and + // the re-attach step re-inserts the comment after the sibling's fresh comma. + if (text[i] === ',') { + i++; + while (i < text.length && (text[i] === ' ' || text[i] === '\t')) { + i++; + } + } if (text[i] === '/' && text[i + 1] === '/') { let end = i; while (end < text.length && text[end] !== '\n') { diff --git a/packages/void/test/unit/wrangler.test.ts b/packages/void/test/unit/wrangler.test.ts index 06b764883..eee8fc470 100644 --- a/packages/void/test/unit/wrangler.test.ts +++ b/packages/void/test/unit/wrangler.test.ts @@ -1173,4 +1173,195 @@ describe('writeProvisionedWranglerConfig', () => { expect(second.modified).toBe(false); expect(readRaw('wrangler.jsonc')).toBe(afterFirst); }); + + it('inserting migrations_dir keeps a trailing comment that sits AFTER a separator comma on its property', () => { + // The last property carries a JSONC trailing comma BEFORE its inline comment + // (`"database_id": "prod-id", // …`). A naive scan sees the comma first and + // gives up, letting the plain formatting edit reassociate the comment onto + // the inserted migrations_dir. The comment must stay with database_id. + setup( + 'wrangler.jsonc', + `{ + "d1_databases": [ + { + "binding": "DB", + "database_name": "app", + "database_id": "prod-id", // production database + } + ] +}`, + ); + + const result = writeProvisionedWranglerConfig(tmpDir, { + d1MigrationsDir: { binding: 'DB', dir: 'db/migrations' }, + }); + expect(result.modified).toBe(true); + expect(result.d1MigrationsDirSkipped).toBe(false); + + const raw = readRaw('wrangler.jsonc'); + // Comment stays attached to database_id, NOT migrations_dir. + expect(raw).toContain('"database_id": "prod-id", // production database'); + expect(raw).not.toMatch(/migrations_dir[^\n]*production database/); + + const d1 = parseRaw('wrangler.jsonc').d1_databases as Array>; + expect(d1[0]).toMatchObject({ binding: 'DB', migrations_dir: 'db/migrations' }); + + // Idempotent — a second run writes nothing and is byte-identical. + const afterFirst = readRaw('wrangler.jsonc'); + const second = writeProvisionedWranglerConfig(tmpDir, { + d1MigrationsDir: { binding: 'DB', dir: 'db/migrations' }, + }); + expect(second.modified).toBe(false); + expect(readRaw('wrangler.jsonc')).toBe(afterFirst); + }); + + it('inserting migrations_dir keeps a single-line block comment that sits after a separator comma', () => { + setup( + 'wrangler.jsonc', + `{ + "d1_databases": [ + { + "binding": "DB", + "database_name": "app", + "database_id": "prod-id", /* production database */ + } + ] +}`, + ); + + const result = writeProvisionedWranglerConfig(tmpDir, { + d1MigrationsDir: { binding: 'DB', dir: 'db/migrations' }, + }); + expect(result.modified).toBe(true); + + const raw = readRaw('wrangler.jsonc'); + expect(raw).toContain('"database_id": "prod-id", /* production database */'); + expect(raw).not.toMatch(/migrations_dir[^\n]*production database/); + + // Idempotent. + const afterFirst = readRaw('wrangler.jsonc'); + const second = writeProvisionedWranglerConfig(tmpDir, { + d1MigrationsDir: { binding: 'DB', dir: 'db/migrations' }, + }); + expect(second.modified).toBe(false); + expect(readRaw('wrangler.jsonc')).toBe(afterFirst); + }); + + it('creating a missing queues.producers section keeps the trailing comment on the existing consumers entry', () => { + // Only `consumers` exists (with a trailing inline comment). Adding the + // `producers` section makes modify() append `producers` as a NEW member of + // the `queues` object, right after `consumers`. The `// jobs consumer` + // comment must stay on the consumers entry, not move onto producers. + setup( + 'wrangler.jsonc', + `{ + "queues": { + "consumers": [ + { "queue": "jobs", "max_batch_size": 10 } + ] // jobs consumer + } +}`, + ); + + const result = writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'emails', binding: 'QUEUE_EMAILS' }] }, + }); + expect(result.modified).toBe(true); + + const raw = readRaw('wrangler.jsonc'); + // Comment stays with consumers (its comma comes before it), never on producers. + expect(raw).toContain('], // jobs consumer'); + expect(raw.indexOf('// jobs consumer')).toBeGreaterThan(raw.indexOf('"consumers"')); + expect(raw.indexOf('// jobs consumer')).toBeLessThan(raw.indexOf('"producers"')); + expect(raw).not.toMatch(/"producers"[\s\S]*jobs consumer/); + + const q = parseRaw('wrangler.jsonc').queues as { + producers: Array>; + consumers: Array>; + }; + expect(q.consumers).toEqual([{ queue: 'jobs', max_batch_size: 10 }]); + expect(q.producers).toEqual([{ queue: 'emails', binding: 'QUEUE_EMAILS' }]); + + // Idempotent — a second run writes nothing and is byte-identical. + const afterFirst = readRaw('wrangler.jsonc'); + const second = writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'emails', binding: 'QUEUE_EMAILS' }] }, + }); + expect(second.modified).toBe(false); + expect(readRaw('wrangler.jsonc')).toBe(afterFirst); + }); + + it('creating a missing queues.producers section keeps a single-line block comment on the consumers entry', () => { + setup( + 'wrangler.jsonc', + `{ + "queues": { + "consumers": [ + { "queue": "jobs", "max_batch_size": 10 } + ] /* jobs consumer */ + } +}`, + ); + + const result = writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'emails', binding: 'QUEUE_EMAILS' }] }, + }); + expect(result.modified).toBe(true); + + const raw = readRaw('wrangler.jsonc'); + expect(raw).toContain('], /* jobs consumer */'); + expect(raw.indexOf('/* jobs consumer */')).toBeLessThan(raw.indexOf('"producers"')); + expect(raw).not.toMatch(/"producers"[\s\S]*jobs consumer/); + + // Idempotent. + const afterFirst = readRaw('wrangler.jsonc'); + const second = writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'emails', binding: 'QUEUE_EMAILS' }] }, + }); + expect(second.modified).toBe(false); + expect(readRaw('wrangler.jsonc')).toBe(afterFirst); + }); + + it('KNOWN LIMITATION: jsonc-parser modify() reassociates a MULTI-LINE block comment used as a trailing comment; comment text is preserved, association is best-effort', () => { + // A block comment that spans multiple lines cannot be safely relocated onto + // a single line, so readTrailingComment declines it and the plain formatting + // edit runs. jsonc-parser then re-emits the comment at the end of the + // reflowed region — moving it onto the appended entry. We PIN this: the hard + // guarantee is that the comment TEXT survives; exact association is + // best-effort for this exotic placement and is not chased further. + setup( + 'wrangler.jsonc', + `{ + "queues": { + "producers": [ + { "queue": "jobs", "binding": "QUEUE_JOBS" } /* jobs + producer spanning lines */ + ] + } +}`, + ); + + const result = writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'emails', binding: 'QUEUE_EMAILS' }] }, + }); + expect(result.modified).toBe(true); + + const raw = readRaw('wrangler.jsonc'); + // Hard guarantee: the comment text is never dropped. + expect(raw).toContain('producer spanning lines'); + // Both producers are present and parse cleanly. + const q = parseRaw('wrangler.jsonc').queues as { producers: Array> }; + expect(q.producers).toEqual([ + { queue: 'jobs', binding: 'QUEUE_JOBS' }, + { queue: 'emails', binding: 'QUEUE_EMAILS' }, + ]); + + // Still idempotent despite the best-effort association. + const afterFirst = readRaw('wrangler.jsonc'); + const second = writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'emails', binding: 'QUEUE_EMAILS' }] }, + }); + expect(second.modified).toBe(false); + expect(readRaw('wrangler.jsonc')).toBe(afterFirst); + }); }); From 78ecd0622b46e6d46ca8dc7e28da1789d33d02b7 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 16 Jul 2026 10:23:16 +0800 Subject: [PATCH 012/128] fix(cli): CR-only line endings + order-independent producer conflict in wrangler writer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two high-severity edge cases in writeProvisionedWranglerConfig (Task 3): 1. CR-only (classic-Mac `\r`) line endings corrupted trailing-comment relocation: readTrailingComment's `//` scanner stopped only at `\n`, so on a CR-only file it consumed the rest of the line (including the closing brace), writing the new member outside the root object → JSONC parse error, and a second run threw instead of returning modified:false. - readTrailingComment now stops a line comment at BOTH `\r` and `\n`, and declines a `/* */` block containing either. - applyMemberEdit gains a validate-before-write backstop: the comment-relocated string is re-parsed and compared (jsonEqual) against the plain modify()+applyEdits() result; on parse failure or data mismatch it discards the relocation and returns the plain edit. Relocation is now safe by construction — it can only ever change comment placement, never JSONC validity or data. 2. Producer conflict detection was order-dependent: it used findIndex and inspected only the first binding match, so a config with one binding bound to two queues (already undeployable) would falsely report convergence or throw depending on array order. Now inspects every producer sharing the desired binding: 0 → append, 1 same queue → no-op, 1 different queue → throw, >1 (duplicate binding) → throw. Consumers still key on queue. Adds regressions for CR-only object/array/missing-section appends and producer order-independence (both array orders). 69 → 74 wrangler tests, all green; vp run check clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/cli/wrangler.ts | 101 +++++++++---- packages/void/test/unit/wrangler.test.ts | 174 ++++++++++++++++++++++- 2 files changed, 246 insertions(+), 29 deletions(-) diff --git a/packages/void/src/cli/wrangler.ts b/packages/void/src/cli/wrangler.ts index 618895589..4f33ae914 100644 --- a/packages/void/src/cli/wrangler.ts +++ b/packages/void/src/cli/wrangler.ts @@ -914,30 +914,51 @@ export function writeProvisionedWranglerConfig( // legal same-queue producers, so a `{queue,CUSTOM}` + `{queue,QUEUE_JOBS}` // pair collapses into two identical `QUEUE_JOBS` bindings — a config // wrangler rejects. - const index = - section === 'producers' - ? currentList.findIndex( - (existing) => isRecord(existing) && existing.binding === entry.binding, - ) - : currentList.findIndex( - (existing) => isRecord(existing) && existing.queue === entry.queue, - ); - - // A producer `binding` must map to exactly ONE queue. If this binding is - // already bound to a DIFFERENT queue, upserting would leave two producers - // sharing one binding — a config wrangler rejects. Fail closed (before any - // write) with an actionable message rather than emitting the broken config. - if (section === 'producers' && index >= 0) { - const existing = currentList[index]; - if (isRecord(existing) && existing.queue !== entry.queue) { + // + // A producer `binding` must map to exactly ONE queue, so the fail-closed + // guard must inspect EVERY producer sharing the desired binding — not just + // the first `findIndex` hit — to stay deterministic regardless of array + // order: + // - 0 matches → append the new producer (`index = -1`). + // - 1 match, same queue → no-op upsert (idempotent). + // - 1 match, different queue → fail closed (binding bound elsewhere). + // - >1 match (duplicate binding across entries) → fail closed; the + // registry is already undeployable, so never silently "converge" on it + // (an order-dependent first-match check would falsely report success). + let index: number; + if (section === 'producers') { + const matches: Array = []; + for (let i = 0; i < currentList.length; i++) { + const existing = currentList[i]; + if (isRecord(existing) && existing.binding === entry.binding) { + matches.push(i); + } + } + if (matches.length > 1) { throw new Error( `deploy: cannot update "${configPath}" — queue producer binding ` + - `${JSON.stringify(entry.binding)} is already bound to queue ` + - `${JSON.stringify(existing.queue)}, but queue ${JSON.stringify(entry.queue)} also ` + - `requests it. A producer binding must map to exactly one queue. Rename or remove ` + - `the conflicting producer and retry.`, + `${JSON.stringify(entry.binding)} is declared ${matches.length} times across ` + + `producers. A producer binding must map to exactly one queue, so wrangler rejects ` + + `this config. Remove the duplicate producers and retry.`, ); } + if (matches.length === 1) { + const existing = currentList[matches[0]]; + if (isRecord(existing) && existing.queue !== entry.queue) { + throw new Error( + `deploy: cannot update "${configPath}" — queue producer binding ` + + `${JSON.stringify(entry.binding)} is already bound to queue ` + + `${JSON.stringify(existing.queue)}, but queue ${JSON.stringify(entry.queue)} also ` + + `requests it. A producer binding must map to exactly one queue. Rename or remove ` + + `the conflicting producer and retry.`, + ); + } + } + index = matches.length === 1 ? matches[0] : -1; + } else { + index = currentList.findIndex( + (existing) => isRecord(existing) && existing.queue === entry.queue, + ); } if (index >= 0) { @@ -1098,7 +1119,26 @@ function applyMemberEdit( if (commaPos === -1) { return plain(); } - return `${rewritten.slice(0, commaPos + 1)} ${trailing.text}${rewritten.slice(commaPos + 1)}`; + const relocated = `${rewritten.slice(0, commaPos + 1)} ${trailing.text}${rewritten.slice(commaPos + 1)}`; + + // Validate-before-write backstop. Comment relocation must ONLY ever change + // comment placement — never JSONC validity or data. Re-parse the relocated + // string; if it does not parse, or its data is not deeply equal to the plain + // formatting edit's data, discard it and return the plain (always-valid) + // result. This makes relocation safe by construction: the worst case is a + // comment reassociated by jsonc-parser onto the wrong sibling, but the file is + // always valid JSONC and always carries the correct data — never corruption. + const plainResult = plain(); + let relocatedParsed: Record; + try { + relocatedParsed = parseJsonc(relocated); + } catch { + return plainResult; + } + if (!jsonEqual(relocatedParsed, parseJsonc(plainResult))) { + return plainResult; + } + return relocated; } /** @@ -1132,13 +1172,16 @@ function readTrailingComment( } } if (text[i] === '/' && text[i + 1] === '/') { + // A line comment ends at the first line terminator — CR or LF. Stopping at + // BOTH means a classic-Mac CR-only file terminates the comment here instead + // of swallowing the rest of the line (including the closing brace, which + // would relocate the new member outside the root object and corrupt the + // file); and a CRLF file's trailing `\r` is left out of the comment text and + // preserved on write. let end = i; - while (end < text.length && text[end] !== '\n') { + while (end < text.length && text[end] !== '\n' && text[end] !== '\r') { end++; } - if (end > i && text[end - 1] === '\r') { - end--; - } return { text: text.slice(i, end), end }; } if (text[i] === '/' && text[i + 1] === '*') { @@ -1147,11 +1190,13 @@ function readTrailingComment( return undefined; } const end = close + 2; - // Only a single-line block comment is safe to relocate onto one line. - if (text.slice(i, end).includes('\n')) { + // Only a single-line block comment is safe to relocate onto one line. A CR + // or LF inside makes it multi-line → decline (accepted best-effort path). + const slice = text.slice(i, end); + if (slice.includes('\n') || slice.includes('\r')) { return undefined; } - return { text: text.slice(i, end), end }; + return { text: slice, end }; } return undefined; } diff --git a/packages/void/test/unit/wrangler.test.ts b/packages/void/test/unit/wrangler.test.ts index eee8fc470..49bcc9cf8 100644 --- a/packages/void/test/unit/wrangler.test.ts +++ b/packages/void/test/unit/wrangler.test.ts @@ -1,5 +1,5 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { parse as parseJsoncRaw } from 'jsonc-parser'; +import { parse as parseJsoncRaw, type ParseError } from 'jsonc-parser'; import { join } from 'pathe'; import { describe, it, expect, afterEach } from 'vite-plus/test'; import { @@ -28,6 +28,13 @@ afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); }); +/** Count jsonc-parser parse errors — used to assert CR-only edits stay valid. */ +function parseErrors(text: string): number { + const errs: Array = []; + parseJsoncRaw(text, errs, { allowTrailingComma: true }); + return errs.length; +} + describe('readWranglerCompat', () => { it('resolves the latest known-good compatibility date without writing void.json', () => { rmSync(tmpDir, { recursive: true, force: true }); @@ -1364,4 +1371,169 @@ describe('writeProvisionedWranglerConfig', () => { expect(second.modified).toBe(false); expect(readRaw('wrangler.jsonc')).toBe(afterFirst); }); + + const CR = '\r'; + + it('CR-only line endings: inserting migrations_dir stays valid, keeps the trailing comment, byte-identical on re-run', () => { + // Classic-Mac CR-only file. A `\n`-only comment scanner would swallow the CR + // + the closing brace, writing migrations_dir OUTSIDE the object (parse error) + // and making a second run throw. Stopping the scan at CR keeps it valid. + const fixture = + `{${CR}` + + ` "d1_databases": [${CR}` + + ` {${CR}` + + ` "binding": "DB",${CR}` + + ` "database_id": "prod-id" // entrypoint${CR}` + + ` }${CR}` + + ` ]${CR}` + + `}`; + setup('wrangler.jsonc', fixture); + + const result = writeProvisionedWranglerConfig(tmpDir, { + d1MigrationsDir: { binding: 'DB', dir: 'db/migrations' }, + }); + expect(result.modified).toBe(true); + expect(result.d1MigrationsDirSkipped).toBe(false); + + const raw = readRaw('wrangler.jsonc'); + // Zero parse errors — the closing brace was NOT consumed by the comment scan. + expect(parseErrors(raw)).toBe(0); + // Comment text preserved and still on database_id (not moved onto migrations_dir). + expect(raw).toContain('// entrypoint'); + expect(raw).not.toMatch(/migrations_dir[^\r\n]*entrypoint/); + const d1 = parseRaw('wrangler.jsonc').d1_databases as Array>; + expect(d1[0]).toMatchObject({ binding: 'DB', migrations_dir: 'db/migrations' }); + + // Byte-identical second run — previously the corrupted file made this THROW. + const afterFirst = raw; + const second = writeProvisionedWranglerConfig(tmpDir, { + d1MigrationsDir: { binding: 'DB', dir: 'db/migrations' }, + }); + expect(second.modified).toBe(false); + expect(readRaw('wrangler.jsonc')).toBe(afterFirst); + }); + + it('CR-only line endings: appending a queue producer stays valid, keeps the trailing comment, byte-identical on re-run', () => { + const fixture = + `{${CR}` + + ` "queues": {${CR}` + + ` "producers": [${CR}` + + ` { "queue": "jobs", "binding": "QUEUE_JOBS" } // jobs producer${CR}` + + ` ]${CR}` + + ` }${CR}` + + `}`; + setup('wrangler.jsonc', fixture); + + const result = writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'emails', binding: 'QUEUE_EMAILS' }] }, + }); + expect(result.modified).toBe(true); + + const raw = readRaw('wrangler.jsonc'); + expect(parseErrors(raw)).toBe(0); + expect(raw).toContain('// jobs producer'); + // Comment stays with the jobs entry, never reassociated onto the appended one. + expect(raw).not.toMatch(/QUEUE_EMAILS[\s\S]*jobs producer/); + const q = parseRaw('wrangler.jsonc').queues as { producers: Array> }; + expect(q.producers).toEqual([ + { queue: 'jobs', binding: 'QUEUE_JOBS' }, + { queue: 'emails', binding: 'QUEUE_EMAILS' }, + ]); + + const afterFirst = raw; + const second = writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'emails', binding: 'QUEUE_EMAILS' }] }, + }); + expect(second.modified).toBe(false); + expect(readRaw('wrangler.jsonc')).toBe(afterFirst); + }); + + it('CR-only line endings: creating a missing queues.producers section stays valid, keeps the consumers trailing comment', () => { + const fixture = + `{${CR}` + + ` "queues": {${CR}` + + ` "consumers": [${CR}` + + ` { "queue": "jobs", "max_batch_size": 10 }${CR}` + + ` ] // jobs consumer${CR}` + + ` }${CR}` + + `}`; + setup('wrangler.jsonc', fixture); + + const result = writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'emails', binding: 'QUEUE_EMAILS' }] }, + }); + expect(result.modified).toBe(true); + + const raw = readRaw('wrangler.jsonc'); + expect(parseErrors(raw)).toBe(0); + expect(raw).toContain('// jobs consumer'); + expect(raw).not.toMatch(/"producers"[\s\S]*jobs consumer/); + const q = parseRaw('wrangler.jsonc').queues as { + producers: Array>; + consumers: Array>; + }; + expect(q.consumers).toEqual([{ queue: 'jobs', max_batch_size: 10 }]); + expect(q.producers).toEqual([{ queue: 'emails', binding: 'QUEUE_EMAILS' }]); + + const afterFirst = raw; + const second = writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'emails', binding: 'QUEUE_EMAILS' }] }, + }); + expect(second.modified).toBe(false); + expect(readRaw('wrangler.jsonc')).toBe(afterFirst); + }); + + it('producer conflict is order-independent: a duplicate binding across producers fails closed in EITHER array order', () => { + // Both entries share binding QUEUE_JOBS (already undeployable). The desired + // {jobs,QUEUE_JOBS} must fail closed regardless of order — a first-match check + // would falsely report convergence when {jobs,...} happens to come first. + const forward = + `{\n` + + ` "queues": {\n` + + ` "producers": [\n` + + ` { "queue": "jobs", "binding": "QUEUE_JOBS" },\n` + + ` { "queue": "other", "binding": "QUEUE_JOBS" }\n` + + ` ]\n` + + ` }\n` + + `}`; + setup('wrangler.jsonc', forward); + expect(() => + writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'jobs', binding: 'QUEUE_JOBS' }] }, + }), + ).toThrow(/declared 2 times/); + expect(readRaw('wrangler.jsonc')).toBe(forward); + + const reversed = + `{\n` + + ` "queues": {\n` + + ` "producers": [\n` + + ` { "queue": "other", "binding": "QUEUE_JOBS" },\n` + + ` { "queue": "jobs", "binding": "QUEUE_JOBS" }\n` + + ` ]\n` + + ` }\n` + + `}`; + setup('wrangler.jsonc', reversed); + expect(() => + writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'jobs', binding: 'QUEUE_JOBS' }] }, + }), + ).toThrow(/declared 2 times/); + expect(readRaw('wrangler.jsonc')).toBe(reversed); + }); + + it('producer upsert is a no-op when the sole matching binding already maps to the same queue', () => { + const fixture = + `{\n` + + ` "queues": {\n` + + ` "producers": [{ "queue": "jobs", "binding": "QUEUE_JOBS" }]\n` + + ` }\n` + + `}`; + setup('wrangler.jsonc', fixture); + const result = writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'jobs', binding: 'QUEUE_JOBS' }] }, + }); + expect(result.modified).toBe(false); + expect(readRaw('wrangler.jsonc')).toBe(fixture); + }); }); From ffbb22ffcff4b109008e2f80663fe663d8cd02c3 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 16 Jul 2026 10:48:17 +0800 Subject: [PATCH 013/128] fix(cli): global comment-preservation invariant in wrangler writer Add a final gate to writeProvisionedWranglerConfig that structurally guarantees the writer's contract: every comment's TEXT survives a write. Extract the comment-token multiset (createScanner, exact source slices) from the original and final content; if any original token is missing from the output, throw WITHOUT writing. Catches the composite-overwrite drop (modify() replacing a scalar-expected field whose object/array value nests a comment, e.g. account_id/migrations_dir/a queue option) and any future pathological path. Relocation only moves a comment (multiset preserved), so valid edits and idempotence are byte-unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/cli/wrangler.ts | 89 +++++++++++++++ packages/void/test/unit/wrangler.test.ts | 131 +++++++++++++++++++++++ 2 files changed, 220 insertions(+) diff --git a/packages/void/src/cli/wrangler.ts b/packages/void/src/cli/wrangler.ts index 4f33ae914..9df9f4ef8 100644 --- a/packages/void/src/cli/wrangler.ts +++ b/packages/void/src/cli/wrangler.ts @@ -2,10 +2,12 @@ import { createHash } from 'node:crypto'; import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { applyEdits, + createScanner, findNodeAtLocation, modify, parse, parseTree, + SyntaxKind, type FormattingOptions, type Node, type ParseError, @@ -850,7 +852,15 @@ export function writeProvisionedWranglerConfig( const formattingOptions = detectFormattingOptions(original); let text = original; + // Paths this call overwrites with a scalar. When a scalar-expected field + // (account_id, migrations_dir, a queue option) already holds a COMPOSITE + // object/array value, `jsonc-parser.modify()` replaces the whole node — + // deleting any comment nested inside it while still reporting the edit as + // applied. The final comment-preservation gate below uses these paths to name + // the offending field when it detects such a drop. + const scalarSetPaths: Array> = []; const set = (path: Array, value: unknown): void => { + scalarSetPaths.push(path); text = applyMemberEdit(text, path, value, { formattingOptions }); }; @@ -1000,6 +1010,44 @@ export function writeProvisionedWranglerConfig( upsertQueueEntries('consumers', patch.queues.consumers); } + // FINAL GATE — global comment-text-preservation invariant. The writer's + // contract is that every comment's TEXT survives a write. Relocation only + // MOVES a comment (identical text, new position), so the multiset of comment + // TOKENS is preserved and this gate passes. A DROPPED comment — the + // composite-overwrite case above, or any other pathological path — makes the + // output multiset miss a token. Assert the final output is a SUPERSET of the + // original's comment tokens BEFORE writing; on any drop, throw WITHOUT writing + // so the file is never corrupted (the BOM re-add + write happen only past this + // gate). O(n): one scanner pass over each string. + const droppedComment = findDroppedComment( + extractCommentTokens(original), + extractCommentTokens(text), + ); + if (droppedComment !== undefined) { + // Prefer a specific diagnosis when the cause is a scalar-expected field that + // held a composite (object/array) value in the ORIGINAL — `modify()` would + // have replaced the whole node, dropping the nested comment. + const originalRoot = parseTree(original, undefined, { allowTrailingComma: true }); + const compositeField = originalRoot + ? scalarSetPaths.find((path) => { + const node = findNodeAtLocation(originalRoot, path); + return node?.type === 'object' || node?.type === 'array'; + }) + : undefined; + if (compositeField) { + const field = String(compositeField[compositeField.length - 1]); + throw new Error( + `deploy: cannot update "${configPath}" — "${field}" has an object/array value; ` + + `expected a string. Fix the config and retry.`, + ); + } + throw new Error( + `deploy: cannot update "${configPath}" — this edit would drop a comment ` + + `(${droppedComment}); the writer refuses to corrupt the file — simplify the ` + + `surrounding structure and retry.`, + ); + } + const finalText = hasBom ? `${UTF8_BOM}${text}` : text; const modified = finalText !== rawContent; if (modified) { @@ -1008,6 +1056,47 @@ export function writeProvisionedWranglerConfig( return { configPath, modified, d1MigrationsDirSkipped }; } +/** + * Extract the multiset of comment TOKENS (line + block) from JSONC source using + * jsonc-parser's scanner — exact by construction, no regex. Each token is its + * verbatim source slice, so a relocated comment keeps identical text (its + * multiset entry is unchanged) while a dropped comment removes one. + */ +function extractCommentTokens(text: string): Array { + const scanner = createScanner(text, false); + const tokens: Array = []; + let kind = scanner.scan(); + while (kind !== SyntaxKind.EOF) { + if (kind === SyntaxKind.LineCommentTrivia || kind === SyntaxKind.BlockCommentTrivia) { + const offset = scanner.getTokenOffset(); + tokens.push(text.slice(offset, offset + scanner.getTokenLength())); + } + kind = scanner.scan(); + } + return tokens; +} + +/** + * Multiset-subset check: return the first comment token present in `before` but + * missing (accounting for repeats) from `after`, or `undefined` when `after` is + * a superset of `before`. The final gate uses this to prove no comment TEXT was + * dropped by the edits. + */ +function findDroppedComment(before: Array, after: Array): string | undefined { + const counts = new Map(); + for (const token of after) { + counts.set(token, (counts.get(token) ?? 0) + 1); + } + for (const token of before) { + const remaining = counts.get(token) ?? 0; + if (remaining <= 0) { + return token; + } + counts.set(token, remaining - 1); + } + return undefined; +} + /** * Detect indentation from an existing config so `jsonc-parser` formats inserted * content to match. Falls back to two-space indentation (the repo default). diff --git a/packages/void/test/unit/wrangler.test.ts b/packages/void/test/unit/wrangler.test.ts index 49bcc9cf8..fee9b13f3 100644 --- a/packages/void/test/unit/wrangler.test.ts +++ b/packages/void/test/unit/wrangler.test.ts @@ -1536,4 +1536,135 @@ describe('writeProvisionedWranglerConfig', () => { expect(result.modified).toBe(false); expect(readRaw('wrangler.jsonc')).toBe(fixture); }); + + // Global comment-text-preservation invariant (final gate before write). The + // writer's contract is that every comment's TEXT survives a write. A + // scalar-expected field holding a composite object/array value with a comment + // inside is a case jsonc-parser's modify() silently drops (it replaces the + // whole node yet reports modified:true). The final gate compares the comment + // token multiset of the original vs the final output and throws WITHOUT + // writing on any drop, rather than chasing each pathological path. + + it('fails closed when a composite account_id value drops an inner comment — throws without writing', () => { + // `account_id` is scalar-expected but holds an object with a comment inside. + // modify() replaces the whole node (deleting the comment) and reports + // success; the final gate must catch the dropped comment and refuse to write. + const fixture = `{ + "account_id": { "x": 1 /* keep this note */ }, + "name": "app" +}`; + setup('wrangler.jsonc', fixture); + expect(() => writeProvisionedWranglerConfig(tmpDir, { accountId: 'acc-999' })).toThrow( + /"account_id" has an object\/array value; expected a string/, + ); + // Fail closed — the file is byte-for-byte untouched. + expect(readRaw('wrangler.jsonc')).toBe(fixture); + }); + + it('fails closed when a composite migrations_dir on the matched D1 entry drops an inner comment — throws without writing', () => { + const fixture = `{ + "d1_databases": [ + { + "binding": "DB", + "database_name": "app", + "database_id": "id", + "migrations_dir": { "dir": "old" /* keep this note */ } + } + ] +}`; + setup('wrangler.jsonc', fixture); + expect(() => + writeProvisionedWranglerConfig(tmpDir, { + d1MigrationsDir: { binding: 'DB', dir: 'db/migrations' }, + }), + ).toThrow(/"migrations_dir" has an object\/array value; expected a string/); + expect(readRaw('wrangler.jsonc')).toBe(fixture); + }); + + it('fails closed when a composite consumer option (max_batch_size) drops an inner comment — throws without writing', () => { + const fixture = `{ + "queues": { + "consumers": [ + { "queue": "jobs", "max_batch_size": { "n": 1 /* keep this note */ } } + ] + } +}`; + setup('wrangler.jsonc', fixture); + expect(() => + writeProvisionedWranglerConfig(tmpDir, { + queues: { consumers: [{ queue: 'jobs', max_batch_size: 25 }] }, + }), + ).toThrow(/"max_batch_size" has an object\/array value; expected a string/); + expect(readRaw('wrangler.jsonc')).toBe(fixture); + }); + + it('does NOT fire on a normal comment-rich config — upsert succeeds, every comment survives, idempotent', () => { + // Negative control: the invariant must never fire for a valid edit. A config + // with several comments upserts cleanly, keeps ALL comments, and a second run + // is byte-identical (the gate does not perturb idempotence). + setup('wrangler.jsonc', commentedFixture); + const patch = { + accountId: 'acc-ok', + name: 'my-app', + compatibilityDate: '2026-02-24', + d1MigrationsDir: { binding: 'DB', dir: 'db/migrations' }, + queues: { + producers: [{ queue: 'jobs', binding: 'QUEUE_JOBS' }], + consumers: [{ queue: 'jobs', max_batch_size: 10 }], + }, + }; + + const first = writeProvisionedWranglerConfig(tmpDir, patch); + expect(first.modified).toBe(true); + + const raw = readRaw('wrangler.jsonc'); + // Every original comment token is still present. + expect(raw).toContain('// Void manages this file'); + expect(raw).toContain('/* App databases'); + + const second = writeProvisionedWranglerConfig(tmpDir, patch); + expect(second.modified).toBe(false); + expect(readRaw('wrangler.jsonc')).toBe(raw); + }); + + it('does NOT fire on a relocation — appending after a trailing-commented sibling keeps every comment', () => { + // Relocation MOVES a comment (same text, new position), so the comment-token + // multiset is unchanged and the invariant passes. Appending a queue producer + // after a sibling that carries a trailing inline comment must succeed. + setup( + 'wrangler.jsonc', + `{ + // file-level note + "queues": { + "producers": [ + { "queue": "jobs", "binding": "QUEUE_JOBS" } // jobs producer + ] + } +}`, + ); + + const result = writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'emails', binding: 'QUEUE_EMAILS' }] }, + }); + expect(result.modified).toBe(true); + + const raw = readRaw('wrangler.jsonc'); + // Both comments survive; the trailing one stays on the jobs entry. + expect(raw).toContain('// file-level note'); + expect(raw).toContain('// jobs producer'); + expect(raw).toContain('}, // jobs producer'); + + const q = parseRaw('wrangler.jsonc').queues as { producers: Array> }; + expect(q.producers).toEqual([ + { queue: 'jobs', binding: 'QUEUE_JOBS' }, + { queue: 'emails', binding: 'QUEUE_EMAILS' }, + ]); + + // Idempotent. + const second = writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'emails', binding: 'QUEUE_EMAILS' }] }, + }); + expect(second.modified).toBe(false); + expect(readRaw('wrangler.jsonc')).toBe(raw); + }); }); From 02a4482060a0b838682d3455fc128d156bdf8e1d Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 16 Jul 2026 11:21:16 +0800 Subject: [PATCH 014/128] fix(cli): worker-wide producer binding uniqueness + migrations_dir/pattern consistency in wrangler writer Two fail-closed guards on writeProvisionedWranglerConfig so it never emits a config wrangler rejects at deploy: - Producer append now checks worker-wide binding-name uniqueness. Before appending a QUEUE_ producer (0-match branch only), collect every binding name used anywhere in the config (mirrors wrangler's validateBindingsHaveUniqueNames field set, minus queues) and throw on a cross-type collision (KV/D1/R2/DO/service/var/AI/hyperdrive/vectorize/...). Same-queue idempotent path is unaffected. - Overwriting migrations_dir now validates a companion migrations_pattern first. If the existing (user-owned) pattern would not be under the desired dir (normalized, per wrangler's stripDirPrefix rule), throw without writing instead of silently invalidating it. Adds 8 regression tests (87 total, all green). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/cli/wrangler.ts | 205 ++++++++++++++++++++++- packages/void/test/unit/wrangler.test.ts | 182 ++++++++++++++++++++ 2 files changed, 385 insertions(+), 2 deletions(-) diff --git a/packages/void/src/cli/wrangler.ts b/packages/void/src/cli/wrangler.ts index 9df9f4ef8..6628611c2 100644 --- a/packages/void/src/cli/wrangler.ts +++ b/packages/void/src/cli/wrangler.ts @@ -12,7 +12,7 @@ import { type Node, type ParseError, } from 'jsonc-parser'; -import { join } from 'pathe'; +import { join, normalize } from 'pathe'; import type { VoidConfig } from '../config.ts'; import type { InferredBindings } from '../inference/walk.ts'; import type { WebSocketRouteDefinition } from '../router/websocket.ts'; @@ -882,16 +882,47 @@ export function writeProvisionedWranglerConfig( let d1MigrationsDirSkipped = false; if (patch.d1MigrationsDir) { const d1Entries = Array.isArray(parsed.d1_databases) - ? (parsed.d1_databases as Array<{ binding?: unknown }>) + ? (parsed.d1_databases as Array<{ binding?: unknown; migrations_pattern?: unknown }>) : []; const index = d1Entries.findIndex((entry) => entry.binding === patch.d1MigrationsDir!.binding); if (index >= 0) { + // A companion `migrations_pattern` (if the user set one) must stay valid + // under the NEW `migrations_dir`: wrangler requires the normalized pattern + // to start with `${migrations_dir}/` (unless the dir normalizes to "."). + // Overwriting `migrations_dir` without checking would invalidate a + // currently-valid pair — e.g. drizzle's `migrations_dir:"drizzle"` + + // `migrations_pattern:"drizzle/*/migration.sql"` — so `wrangler d1 + // migrations` create/list/apply then fail. Validate before editing; fail + // closed rather than emit a config wrangler rejects, and NEVER rewrite the + // user-owned pattern. No pattern (or an already-compatible one) → behave as + // before (set / idempotent no-op). + const existingPattern = d1Entries[index].migrations_pattern; + if ( + typeof existingPattern === 'string' && + !migrationsPatternMatchesDir(existingPattern, patch.d1MigrationsDir.dir) + ) { + throw new Error( + `deploy: cannot update "${configPath}" — the D1 entry for binding ` + + `${JSON.stringify(patch.d1MigrationsDir.binding)} has "migrations_pattern": ` + + `${JSON.stringify(existingPattern)}, which is not under the requested ` + + `"migrations_dir": ${JSON.stringify(patch.d1MigrationsDir.dir)}. Wrangler requires the ` + + `pattern to start with the migrations dir, so this change would break ` + + `\`wrangler d1 migrations\`. Align "migrations_pattern" with the dir (or remove it) and retry.`, + ); + } set(['d1_databases', index, 'migrations_dir'], patch.d1MigrationsDir.dir); } else { d1MigrationsDirSkipped = true; } } + // Lazily-built map of EVERY binding name already used anywhere in the config + // (keyed name → wrangler's friendly owner label), EXCLUDING `queues` (producer + // identity is handled by the binding-match logic below; consumers carry no + // binding). Used to fail closed before APPENDING a new producer whose binding + // collides with a worker-wide binding name (see the producers branch). + let workerBindingOwners: Map | undefined; + // queues — idempotent upsert into `queues.producers[]` / `queues.consumers[]`. // Each section keys on its wrangler identity: // - PRODUCERS key on `binding`. wrangler requires producer binding names to @@ -965,6 +996,29 @@ export function writeProvisionedWranglerConfig( } } index = matches.length === 1 ? matches[0] : -1; + if (index === -1) { + // APPEND branch only (no producer already owns this binding). wrangler + // requires every binding NAME to be unique across the ENTIRE worker — + // not just within `queues.producers`. So before appending a producer + // whose binding (`QUEUE_`) is ALREADY used by any OTHER binding + // (D1/KV/R2/DO/service/var/AI/hyperdrive/vectorize/…), fail closed: + // emitting it would produce a config wrangler rejects at deploy + // (cross-type duplicate binding name). A same-queue producer already + // owning that binding is a no-op handled above (index >= 0), never a + // collision. + workerBindingOwners ??= collectWorkerBindingNames(parsed); + const bindingName = String(entry.binding); + const owner = workerBindingOwners.get(bindingName); + if (owner !== undefined) { + throw new Error( + `deploy: cannot update "${configPath}" — queue producer binding ` + + `${JSON.stringify(bindingName)} is already used by a ${owner} binding. Wrangler ` + + `requires every binding name to be unique across the whole worker, so appending ` + + `this producer would make the config undeployable. Rename the conflicting binding ` + + `(or the queue) and retry.`, + ); + } + } } else { index = currentList.findIndex( (existing) => isRecord(existing) && existing.queue === entry.queue, @@ -1351,6 +1405,153 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +/** + * wrangler's friendly label for each binding namespace (mirrors + * `friendlyBindingNames` in `wrangler-dist/cli.js`, used by + * `validateBindingsHaveUniqueNames`). Drives the owner name in the worker-wide + * producer-collision error. + * + * `queues` is intentionally ABSENT: producer identity is handled by the upsert's + * binding-match logic and consumers carry no binding, so including it here would + * be redundant. `secrets.required` (which wrangler folds in as "Secret") is also + * excluded — it is not a binding declared in `wrangler.jsonc`. + */ +const FRIENDLY_BINDING_NAMES: Record = { + data_blobs: 'Data Blob', + durable_objects: 'Durable Object', + kv_namespaces: 'KV Namespace', + send_email: 'Send Email', + d1_databases: 'D1 Database', + vectorize: 'Vectorize Index', + ai_search_namespaces: 'AI Search Namespace', + ai_search: 'AI Search Instance', + websearch: 'Web Search', + agent_memory: 'Agent Memory', + hyperdrive: 'Hyperdrive Config', + r2_buckets: 'R2 Bucket', + logfwdr: 'logfwdr', + services: 'Worker', + analytics_engine_datasets: 'Analytics Engine Dataset', + text_blobs: 'Text Blob', + browser: 'Browser Run', + ai: 'AI', + images: 'Images', + stream: 'Stream', + media: 'Media', + version_metadata: 'Worker Version Metadata', + unsafe: 'Unsafe Metadata', + vars: 'Environment Variable', + wasm_modules: 'Wasm Module', + dispatch_namespaces: 'Dispatch Namespace', + mtls_certificates: 'mTLS Certificate', + workflows: 'Workflow', + pipelines: 'Pipeline', + secrets_store_secrets: 'Secrets Store Secret', + artifacts: 'Artifacts', + ratelimits: 'Rate Limit', + assets: 'Assets', + flagship: 'Flagship', + worker_loaders: 'Worker Loader', + vpc_services: 'VPC Service', + vpc_networks: 'VPC Network', +}; + +/** + * Collect every binding NAME already used across the worker config, mapped to + * wrangler's friendly owner label (first writer wins on ties). Mirrors wrangler's + * `validateBindingsHaveUniqueNames` field set (minus `queues`, handled by the + * producer upsert's own identity logic). Used to fail closed before appending a + * queue producer whose binding collides with any other binding. + */ +function collectWorkerBindingNames(config: Record): Map { + const owners = new Map(); + for (const [field, friendly] of Object.entries(FRIENDLY_BINDING_NAMES)) { + for (const name of bindingNamesForField(field, config[field])) { + if (!owners.has(name)) { + owners.set(name, friendly); + } + } + } + return owners; +} + +/** + * Binding names a single config field contributes. Mirrors wrangler's + * `getBindingNames`, with one addition: `send_email` entries key on `.name` + * (wrangler's namespace-list detection keys on `.binding`), so collect those too + * as a safe superset — a producer binding must never silently collide with a + * send_email binding. + */ +function bindingNamesForField(field: string, value: unknown): Array { + const generic = genericBindingNames(value); + if (field === 'send_email' && Array.isArray(value)) { + const names = value + .filter(isRecord) + .map((entry) => entry.name) + .filter((name): name is string => typeof name === 'string'); + return [...generic, ...names]; + } + return generic; +} + +/** + * Mirror wrangler's `getBindingNames`: extract the binding NAMES a config value + * contributes across the four shapes wrangler recognizes — a binding list + * (`{ bindings: [{ name }] }`, e.g. `durable_objects`/`unsafe`), a namespace list + * (`[{ binding }]`, e.g. `kv_namespaces`/`d1_databases`), a single record with a + * `binding` (e.g. `ai`/`browser`/`version_metadata`), and a plain record whose + * keys are the names (`vars`, blob/module maps). + */ +function genericBindingNames(value: unknown): Array { + if (typeof value !== 'object' || value === null) { + return []; + } + if ( + isRecord(value) && + Array.isArray(value.bindings) && + value.bindings.every((binding) => isRecord(binding) && typeof binding.name === 'string') + ) { + return (value.bindings as Array<{ name: string }>).map((binding) => binding.name); + } + if ( + Array.isArray(value) && + value.every((entry) => isRecord(entry) && typeof entry.binding === 'string') + ) { + return (value as Array<{ binding: string }>).map((entry) => entry.binding); + } + if (isRecord(value)) { + if (value.binding !== undefined) { + return typeof value.binding === 'string' ? [value.binding] : []; + } + return Object.keys(value).filter((key) => value[key] !== undefined); + } + return []; +} + +/** + * True when a D1 `migrations_pattern` is valid under `dir` per wrangler's rule: + * the normalized pattern must start with `${normalized dir}/` (or the dir + * normalizes to "."). Mirrors wrangler's `resolveMigrationsConfig` / + * `stripDirPrefix`. + */ +function migrationsPatternMatchesDir(pattern: string, dir: string): boolean { + const normalizedDir = normalizeMigrationsPath(dir); + if (normalizedDir === '.') { + return true; + } + return normalizeMigrationsPath(pattern).startsWith(`${normalizedDir}/`); +} + +/** + * Mirror wrangler's `normalizeRelativePath`: forward-slash, posix-normalize, then + * drop a trailing slash. Keeps the migrations validity check byte-identical with + * wrangler's own. + */ +function normalizeMigrationsPath(p: string): string { + const normalized = normalize(p.replace(/\\/g, '/')); + return normalized.endsWith('/') ? normalized.slice(0, -1) : normalized; +} + /** Order-insensitive structural equality — avoids rewriting an entry that only * differs in key order (preserves the file's bytes/comments on that entry). */ function jsonEqual(a: unknown, b: unknown): boolean { diff --git a/packages/void/test/unit/wrangler.test.ts b/packages/void/test/unit/wrangler.test.ts index fee9b13f3..425dd7817 100644 --- a/packages/void/test/unit/wrangler.test.ts +++ b/packages/void/test/unit/wrangler.test.ts @@ -1097,6 +1097,188 @@ describe('writeProvisionedWranglerConfig', () => { expect(readRaw('wrangler.jsonc')).toBe(before); }); + it('fails closed when the desired producer binding collides with a D1 binding name (worker-wide uniqueness) — throws without writing', () => { + // wrangler requires every binding NAME to be unique across the ENTIRE worker, + // not just within queues.producers. QUEUE_JOBS is already a D1 binding, so + // appending a producer with that binding would emit a cross-type duplicate + // wrangler 4.x rejects at deploy. Fail closed instead. + setup( + 'wrangler.jsonc', + `{ + "d1_databases": [{ "binding": "QUEUE_JOBS", "database_name": "app", "database_id": "id" }] +}`, + ); + const before = readRaw('wrangler.jsonc'); + + expect(() => + writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'jobs', binding: 'QUEUE_JOBS' }] }, + }), + ).toThrow(/"QUEUE_JOBS" is already used by a D1 Database binding/); + + // Fail closed — the file is byte-unchanged. + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('fails closed when the desired producer binding collides with a KV binding name — throws without writing', () => { + setup( + 'wrangler.jsonc', + `{ + "kv_namespaces": [{ "binding": "QUEUE_JOBS", "id": "abc" }] +}`, + ); + const before = readRaw('wrangler.jsonc'); + + expect(() => + writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'jobs', binding: 'QUEUE_JOBS' }] }, + }), + ).toThrow(/"QUEUE_JOBS" is already used by a KV Namespace binding/); + + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('fails closed when the desired producer binding collides with a var name — throws without writing', () => { + setup( + 'wrangler.jsonc', + `{ + "vars": { "QUEUE_JOBS": "some-value" } +}`, + ); + const before = readRaw('wrangler.jsonc'); + + expect(() => + writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'jobs', binding: 'QUEUE_JOBS' }] }, + }), + ).toThrow(/"QUEUE_JOBS" is already used by an? Environment Variable binding/); + + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('negative control: a producer binding that collides with nothing appends normally', () => { + // A DB binding named DB does NOT collide with the QUEUE_JOBS producer binding, + // so the append proceeds — the worker-wide guard is precise, not paranoid. + setup( + 'wrangler.jsonc', + `{ + "d1_databases": [{ "binding": "DB", "database_name": "app", "database_id": "id" }], + "kv_namespaces": [{ "binding": "KV", "id": "abc" }] +}`, + ); + + const result = writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'jobs', binding: 'QUEUE_JOBS' }] }, + }); + expect(result.modified).toBe(true); + + const q = parseRaw('wrangler.jsonc').queues as { producers: Array> }; + expect(q.producers).toEqual([{ queue: 'jobs', binding: 'QUEUE_JOBS' }]); + }); + + it('worker-wide guard does not fire on the idempotent same-queue producer path (no false collision)', () => { + // QUEUE_JOBS already exists as a producer for the SAME queue. That is a no-op + // upsert (index >= 0), so the append-only worker-wide collision guard never + // runs — a producer already owning its binding for its own queue is not a + // collision. + setup( + 'wrangler.jsonc', + `{ + "queues": { + "producers": [{ "queue": "jobs", "binding": "QUEUE_JOBS" }] + } +}`, + ); + const before = readRaw('wrangler.jsonc'); + + const result = writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'jobs', binding: 'QUEUE_JOBS' }] }, + }); + expect(result.modified).toBe(false); + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('fails closed when overwriting migrations_dir would invalidate a companion migrations_pattern — throws without writing', () => { + // A valid drizzle pair (migrations_dir + migrations_pattern) becomes invalid + // when the patch changes migrations_dir, because wrangler requires the pattern + // to start with `${migrations_dir}/`. Fail closed rather than break `wrangler + // d1 migrations`, and never rewrite the user-owned pattern. + setup( + 'wrangler.jsonc', + `{ + "d1_databases": [ + { + "binding": "DB", + "database_name": "app", + "database_id": "id", + "migrations_dir": "drizzle", + "migrations_pattern": "drizzle/*/migration.sql" + } + ] +}`, + ); + const before = readRaw('wrangler.jsonc'); + + expect(() => + writeProvisionedWranglerConfig(tmpDir, { + d1MigrationsDir: { binding: 'DB', dir: './db/migrations' }, + }), + ).toThrow(/"migrations_pattern": "drizzle\/\*\/migration\.sql", which is not under/); + + // Fail closed — the file is byte-unchanged (pattern untouched). + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('migrations_dir upsert proceeds when there is no migrations_pattern (unchanged behavior, idempotent)', () => { + setup( + 'wrangler.jsonc', + `{ + "d1_databases": [ + { "binding": "DB", "database_name": "app", "database_id": "id" } + ] +}`, + ); + + const patch = { d1MigrationsDir: { binding: 'DB', dir: 'drizzle' } }; + const first = writeProvisionedWranglerConfig(tmpDir, patch); + expect(first.modified).toBe(true); + const d1 = parseRaw('wrangler.jsonc').d1_databases as Array>; + expect(d1[0]).toMatchObject({ binding: 'DB', migrations_dir: 'drizzle' }); + + // Idempotent — a second run writes nothing and is byte-identical. + const afterFirst = readRaw('wrangler.jsonc'); + const second = writeProvisionedWranglerConfig(tmpDir, patch); + expect(second.modified).toBe(false); + expect(readRaw('wrangler.jsonc')).toBe(afterFirst); + }); + + it('migrations_dir upsert is a no-op when a compatible migrations_pattern already matches the requested dir', () => { + // The existing pattern is under the requested dir, so setting the (already + // equal) migrations_dir is a byte-idempotent no-op that leaves the pattern + // untouched. + setup( + 'wrangler.jsonc', + `{ + "d1_databases": [ + { + "binding": "DB", + "database_name": "app", + "database_id": "id", + "migrations_dir": "db/migrations", + "migrations_pattern": "db/migrations/0001_init.sql" + } + ] +}`, + ); + const before = readRaw('wrangler.jsonc'); + + const result = writeProvisionedWranglerConfig(tmpDir, { + d1MigrationsDir: { binding: 'DB', dir: 'db/migrations' }, + }); + expect(result.modified).toBe(false); + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + it('inserting migrations_dir keeps a trailing inline comment on the property it documented (no reassociation)', () => { // The D1 entry's LAST property carries a trailing inline comment. Naively // inserting migrations_dir via a formatting modify() reflows the region and From df1ffad7cead85b9f1627a3ed13781630f8b6313 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 16 Jul 2026 11:49:03 +0800 Subject: [PATCH 015/128] fix(cli): complete worker-wide binding-name collector for queue-producer guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The producer-append collision guard's collector missed three valid worker binding-name sources, letting a QUEUE_ producer collide with an existing binding yet still append (modified:true → wrangler rejects the deploy): - secrets.required — wrangler folds these in as "Secret" bindings - unsafe_hello_world — a [{ binding }] array absent from the mirrored map - ratelimits[].name — a .name-keyed array wrangler's getBindingNames overlooks Mirror wrangler's own validateBindingsHaveUniqueNames enumeration: add unsafe_hello_world to FRIENDLY_BINDING_NAMES, fold config.secrets.required in as "Secret", and generalize the send_email .name special-case into NAME_KEYED_BINDING_ARRAYS = { send_email, ratelimits }. workflows keeps its .binding path (its .name is the resource name, not a binding name) to avoid a false positive; queues stays excluded (producer identity is handled by the by-binding upsert). No id-ownership, queue identity, idempotency, comment gate, or migrations logic touched. Adds regressions for each newly-covered source plus a negative control proving top-level name / d1 database_name / consumer queue = QUEUE_JOBS do NOT collide. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/cli/wrangler.ts | 77 +++++++++++---- packages/void/test/unit/wrangler.test.ts | 118 +++++++++++++++++++++++ 2 files changed, 176 insertions(+), 19 deletions(-) diff --git a/packages/void/src/cli/wrangler.ts b/packages/void/src/cli/wrangler.ts index 6628611c2..405fe4cfa 100644 --- a/packages/void/src/cli/wrangler.ts +++ b/packages/void/src/cli/wrangler.ts @@ -1406,15 +1406,19 @@ function isRecord(value: unknown): value is Record { } /** - * wrangler's friendly label for each binding namespace (mirrors - * `friendlyBindingNames` in `wrangler-dist/cli.js`, used by - * `validateBindingsHaveUniqueNames`). Drives the owner name in the worker-wide - * producer-collision error. + * wrangler's friendly label for each binding namespace. Mirrors + * `friendlyBindingNames` in `wrangler-dist/cli.js` — the map + * `validateBindingsHaveUniqueNames` iterates to gather every worker-wide binding + * name. Drives the owner name in the worker-wide producer-collision error. * - * `queues` is intentionally ABSENT: producer identity is handled by the upsert's - * binding-match logic and consumers carry no binding, so including it here would - * be redundant. `secrets.required` (which wrangler folds in as "Secret") is also - * excluded — it is not a binding declared in `wrangler.jsonc`. + * This is a 1:1 copy of wrangler's `friendlyBindingNames` EXCEPT `queues`, which + * is intentionally ABSENT: wrangler folds in `queues.producers`, but here producer + * identity is handled by the upsert's own binding-match logic (and consumers carry + * no binding), so including it would flag the very producer we are about to append + * as a self-collision. Every OTHER field wrangler enumerates is present (including + * `unsafe_hello_world`). `secrets.required` — which wrangler folds in separately + * under the label "Secret", not via this map — is handled in + * `collectWorkerBindingNames`. */ const FRIENDLY_BINDING_NAMES: Record = { data_blobs: 'Data Blob', @@ -1450,6 +1454,7 @@ const FRIENDLY_BINDING_NAMES: Record = { artifacts: 'Artifacts', ratelimits: 'Rate Limit', assets: 'Assets', + unsafe_hello_world: 'Hello World', flagship: 'Flagship', worker_loaders: 'Worker Loader', vpc_services: 'VPC Service', @@ -1458,33 +1463,67 @@ const FRIENDLY_BINDING_NAMES: Record = { /** * Collect every binding NAME already used across the worker config, mapped to - * wrangler's friendly owner label (first writer wins on ties). Mirrors wrangler's - * `validateBindingsHaveUniqueNames` field set (minus `queues`, handled by the - * producer upsert's own identity logic). Used to fail closed before appending a - * queue producer whose binding collides with any other binding. + * wrangler's friendly owner label (first writer wins on ties). Mirrors the exact + * name set `validateBindingsHaveUniqueNames` builds in `wrangler-dist/cli.js`: + * `getBindingNames(config[field])` for every `friendlyBindingNames` field, PLUS + * `config.secrets.required` under the label "Secret" — minus `queues` (see + * FRIENDLY_BINDING_NAMES for why). Used to fail closed before appending a queue + * producer whose binding collides with any other worker-wide binding. */ function collectWorkerBindingNames(config: Record): Map { const owners = new Map(); + const claim = (name: string, friendly: string): void => { + if (!owners.has(name)) { + owners.set(name, friendly); + } + }; for (const [field, friendly] of Object.entries(FRIENDLY_BINDING_NAMES)) { for (const name of bindingNamesForField(field, config[field])) { - if (!owners.has(name)) { - owners.set(name, friendly); + claim(name, friendly); + } + } + // wrangler folds `secrets.required` into the uniqueness set OUTSIDE the + // `friendlyBindingNames` loop, under the friendly label "Secret" (see + // `validateBindingsHaveUniqueNames`: + // `bindingsGroupedByType["Secret"] = config2.secrets?.required ?? []`). These + // ARE worker binding names at deploy, so a queue producer must never silently + // collide with one. + const secrets = config.secrets; + if (isRecord(secrets) && Array.isArray(secrets.required)) { + for (const name of secrets.required) { + if (typeof name === 'string') { + claim(name, 'Secret'); } } } return owners; } +/** + * Binding-name arrays whose entries key the binding name on `.name`, NOT + * `.binding`. wrangler's `getBindingNames` only reads `.binding` from array + * entries (its `isNamespaceList` branch), so it OVERLOOKS these — yet + * `validateSendEmailBinding` and `validateRateLimitBinding` both + * `isRequiredProperty(value, "name")`, so `.name` is the binding name wrangler + * enforces uniqueness on at deploy. We collect them as a safe superset so a queue + * producer can never silently collide with a send_email or ratelimit binding. + * + * NOTE: `workflows` entries also carry a `.name`, but that is the workflow + * RESOURCE name — the binding name is `.binding` — so `workflows` stays OUT of + * this set (its `.binding` is picked up by the generic path). `durable_objects` + * uses the `{ bindings: [{ name }] }` shape, handled by `genericBindingNames`' + * binding-list branch. + */ +const NAME_KEYED_BINDING_ARRAYS = new Set(['send_email', 'ratelimits']); + /** * Binding names a single config field contributes. Mirrors wrangler's - * `getBindingNames`, with one addition: `send_email` entries key on `.name` - * (wrangler's namespace-list detection keys on `.binding`), so collect those too - * as a safe superset — a producer binding must never silently collide with a - * send_email binding. + * `getBindingNames`, plus the `.name`-keyed arrays wrangler's own + * `getBindingNames` overlooks (see NAME_KEYED_BINDING_ARRAYS). */ function bindingNamesForField(field: string, value: unknown): Array { const generic = genericBindingNames(value); - if (field === 'send_email' && Array.isArray(value)) { + if (NAME_KEYED_BINDING_ARRAYS.has(field) && Array.isArray(value)) { const names = value .filter(isRecord) .map((entry) => entry.name) diff --git a/packages/void/test/unit/wrangler.test.ts b/packages/void/test/unit/wrangler.test.ts index 425dd7817..220a96741 100644 --- a/packages/void/test/unit/wrangler.test.ts +++ b/packages/void/test/unit/wrangler.test.ts @@ -1156,6 +1156,124 @@ describe('writeProvisionedWranglerConfig', () => { expect(readRaw('wrangler.jsonc')).toBe(before); }); + it('fails closed when the desired producer binding collides with a secrets.required name — throws without writing', () => { + // wrangler folds `secrets.required` into its worker-wide uniqueness set under + // the label "Secret" (validateBindingsHaveUniqueNames). QUEUE_JOBS is declared + // there, so appending a producer with that binding would emit a cross-type + // duplicate wrangler rejects at deploy. Fail closed instead. + setup( + 'wrangler.jsonc', + `{ + "secrets": { "required": ["QUEUE_JOBS"] } +}`, + ); + const before = readRaw('wrangler.jsonc'); + + expect(() => + writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'jobs', binding: 'QUEUE_JOBS' }] }, + }), + ).toThrow(/"QUEUE_JOBS" is already used by a Secret binding/); + + // Fail closed — the file is byte-unchanged. + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('fails closed when the desired producer binding collides with an unsafe_hello_world binding — throws without writing', () => { + // `unsafe_hello_world` is a `[{ binding }]` array in wrangler's + // friendlyBindingNames set. QUEUE_JOBS is already such a binding, so the + // append would make the config undeployable — fail closed. + setup( + 'wrangler.jsonc', + `{ + "unsafe_hello_world": [{ "binding": "QUEUE_JOBS", "enable_timer": false }] +}`, + ); + const before = readRaw('wrangler.jsonc'); + + expect(() => + writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'jobs', binding: 'QUEUE_JOBS' }] }, + }), + ).toThrow(/"QUEUE_JOBS" is already used by a Hello World binding/); + + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('fails closed when the desired producer binding collides with a ratelimits[].name — throws without writing', () => { + // ratelimit bindings key their NAME on `.name`, not `.binding`, so wrangler's + // own getBindingNames overlooks them — but validateRateLimitBinding requires + // `.name`, making it the binding name enforced at deploy. Collect it as a safe + // superset and fail closed on the collision. + setup( + 'wrangler.jsonc', + `{ + "ratelimits": [{ "name": "QUEUE_JOBS", "namespace_id": "1", "simple": { "limit": 100, "period": 60 } }] +}`, + ); + const before = readRaw('wrangler.jsonc'); + + expect(() => + writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'jobs', binding: 'QUEUE_JOBS' }] }, + }), + ).toThrow(/"QUEUE_JOBS" is already used by a Rate Limit binding/); + + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('fails closed when the desired producer binding collides with a send_email[].name — throws without writing', () => { + // send_email is the other `.name`-keyed binding array (like ratelimits). A + // producer binding must never silently collide with a send_email binding. + setup( + 'wrangler.jsonc', + `{ + "send_email": [{ "name": "QUEUE_JOBS", "destination_address": "ops@example.com" }] +}`, + ); + const before = readRaw('wrangler.jsonc'); + + expect(() => + writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'jobs', binding: 'QUEUE_JOBS' }] }, + }), + ).toThrow(/"QUEUE_JOBS" is already used by a Send Email binding/); + + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('negative control: top-level name, d1 database_name, and consumer queue equal to the producer binding are NOT binding names — append succeeds', () => { + // None of these fields is a binding NAME: the worker `name`, a D1 + // `database_name`, and a queues.consumers[].queue can all legally equal + // QUEUE_JOBS without colliding with the QUEUE_JOBS producer binding. The guard + // must only inspect the binding-bearing fields wrangler enumerates, so this + // append proceeds. + setup( + 'wrangler.jsonc', + `{ + "name": "QUEUE_JOBS", + "d1_databases": [{ "binding": "DB", "database_name": "QUEUE_JOBS", "database_id": "id" }], + "queues": { + "consumers": [{ "queue": "QUEUE_JOBS", "max_batch_size": 10 }] + } +}`, + ); + + const result = writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'jobs', binding: 'QUEUE_JOBS' }] }, + }); + expect(result.modified).toBe(true); + + const q = parseRaw('wrangler.jsonc').queues as { + producers: Array>; + consumers: Array>; + }; + // The producer was appended; the consumer keyed on the same queue string is + // untouched (not a binding-name collision). + expect(q.producers).toEqual([{ queue: 'jobs', binding: 'QUEUE_JOBS' }]); + expect(q.consumers).toEqual([{ queue: 'QUEUE_JOBS', max_batch_size: 10 }]); + }); + it('negative control: a producer binding that collides with nothing appends normally', () => { // A DB binding named DB does NOT collide with the QUEUE_JOBS producer binding, // so the append proceeds — the worker-wide guard is precise, not paranoid. From de6555f12929d61493096db57660c6ca647b93c0 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 16 Jul 2026 12:49:44 +0800 Subject: [PATCH 016/128] feat(deploy): add Cloudflare resource provisioner + writer adopt fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add packages/void/src/cli/provision.ts: given a computed DesiredResourceSet, ensure D1/KV/R2/Hyperdrive/Queues exist in the user's own Cloudflare account by shelling out to wrangler through an injectable `runWrangler` seam (default impl wraps spawnSync on resolveWranglerCommand with a CF-Access-scrubbed env). Read-first + idempotent: a binding already pointing at a real id is left untouched; a missing resource is created via `wrangler create --update-config` (which upserts the id by binding, comment-preservingly); an already-existing one is adopted by capturing its real id (`d1 list/info --json`, `kv namespace list`, `r2 bucket info --json`) and writing it through the Task 3 writer's new adopt fields. Queues create the resource only (no config entries). Extend writeProvisionedWranglerConfig with additive optional adopt fields (d1/kv/r2/hyperdrive) — each an idempotent, comment-preserving upsert-by-binding that creates the entry if absent. All existing writer invariants + tests intact. Verified against wrangler 4.107 source (uuid/7502, JSON-vs-cli-table3 output, and that --update-config upserts by binding). R2/queues existence use ` info` since their `list` is not JSON; hyperdrive account-adoption is deferred to Task 6 (wrangler 4.107 has no machine-readable hyperdrive list). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/cli/provision.ts | 455 +++++++++++++++ packages/void/src/cli/wrangler.ts | 72 ++- packages/void/test/unit/provision.test.ts | 681 ++++++++++++++++++++++ packages/void/test/unit/wrangler.test.ts | 187 ++++++ 4 files changed, 1393 insertions(+), 2 deletions(-) create mode 100644 packages/void/src/cli/provision.ts create mode 100644 packages/void/test/unit/provision.test.ts diff --git a/packages/void/src/cli/provision.ts b/packages/void/src/cli/provision.ts new file mode 100644 index 000000000..99af33904 --- /dev/null +++ b/packages/void/src/cli/provision.ts @@ -0,0 +1,455 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { parse, type ParseError } from 'jsonc-parser'; +import { voidLog, voidWarn } from '../log.ts'; +import { resolveWranglerCommand } from './preset.ts'; +import { writeProvisionedWranglerConfig } from './wrangler.ts'; + +/** + * The set of Cloudflare resources an app needs, computed by the deploy + * orchestrator (Task 6) from inferred bindings + config. The provisioner + * consumes this as-is; it never computes the set itself. + * + * `binding` values are the pre-validated uppercase names the app already uses + * (`DB`, `KV`, `ISR_CACHE`, `STORAGE`, `HYPERDRIVE`) and are passed to wrangler + * verbatim. + */ +export interface DesiredResourceSet { + /** D1 database — `{ binding: 'DB', name: 'myapp-db' }`. */ + d1?: { binding: string; name: string }; + /** KV namespaces (app KV + `ISR_CACHE`). Titles are NOT account-unique. */ + kvNamespaces: Array<{ binding: string; title: string }>; + /** R2 bucket — bucket names ARE account-unique. */ + r2?: { binding: string; bucketName: string }; + /** Queue resource NAMES to ensure exist (account-unique). */ + queues: Array; + /** Hyperdrive config — a connection string is required only to CREATE one. */ + hyperdrive?: { binding: string; name: string; connectionString: string }; +} + +/** Result of one `runWrangler` invocation. */ +export type RunWranglerResult = { stdout: string; stderr: string; status: number }; + +/** + * Seam over shelling out to the project's `wrangler`. The default + * implementation ({@link createDefaultRunWrangler}) wraps `spawnSync`; unit + * tests inject a stub so no real wrangler runs. `subcommand` is the part AFTER + * `wrangler ` (e.g. `d1 list --json`). + */ +export type RunWrangler = ( + subcommand: string, + opts?: { cwd?: string; captureJson?: boolean }, +) => RunWranglerResult; + +/** Per-resource outcome, so the orchestrator can log what happened. */ +export interface ProvisionOutcome { + kind: 'd1' | 'kv' | 'r2' | 'hyperdrive' | 'queue'; + /** Binding name (absent for queue resources, which are not bindings here). */ + binding?: string; + /** Resource name / KV title / bucket name / queue name. */ + name: string; + action: 'created' | 'reused' | 'adopted' | 'skipped'; + /** Real resource id captured on adopt (and reuse, when known). */ + id?: string; + /** Reason, set only when `action === 'skipped'`. */ + reason?: string; +} + +export interface ProvisionResult { + resources: Array; + /** The root wrangler config the writer/wrangler operated on. */ + configPath: string; + /** True when top-level `account_id` was written from `opts.accountId`. */ + accountIdWritten: boolean; +} + +export interface ProvisionLogger { + info: (msg: string) => void; + warn: (msg: string) => void; +} + +/** Where Void keeps D1 migrations — written as `migrations_dir` on the D1 entry. */ +const D1_MIGRATIONS_DIR = './db/migrations'; + +/** + * Values wrangler/Void write as placeholders during local dev + * (`syncWranglerBindings`): a D1 `database_id: 'local'`, a KV `id: 'local'`, an + * R2 `bucket_name: 'default'`. A binding holding one of these is NOT yet backed + * by a real account resource, so provisioning must not treat it as done. + */ +function isRealValue(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && value !== 'local' && value !== 'default'; +} + +const defaultLogger: ProvisionLogger = { + info: (msg) => voidLog(msg), + warn: (msg) => voidWarn(msg), +}; + +/** + * Child-process env with the CF Access service-token PAIR scrubbed. Mirrors + * `buildEnv` in `deploy.ts`: a shared, long-lived Access token is needed only by + * this CLI's own API client and must never leak into the spawned wrangler + * process (which may in turn run user hooks). The cloudflared `CF_ACCESS_TOKEN` + * JWT (dev path) is left untouched. + */ +function provisionEnv(accountId?: string): NodeJS.ProcessEnv { + const env = { ...process.env }; + delete env.CF_ACCESS_CLIENT_ID; + delete env.CF_ACCESS_CLIENT_SECRET; + // Select the target account non-interactively (CI-safe) when known, so + // `wrangler list/create` never prompts for account disambiguation. + if (accountId) { + env.CLOUDFLARE_ACCOUNT_ID = accountId; + } + return env; +} + +/** + * Default `runWrangler`: resolves the project-local wrangler via Task 2's + * {@link resolveWranglerCommand} and runs it with `spawnSync`, capturing stdout + * (for `--json` parsing) and stderr (for error/collision detection). Never + * called in unit tests — those inject a stub. + */ +export function createDefaultRunWrangler( + root: string, + options?: { accountId?: string }, +): RunWrangler { + return (subcommand, opts) => { + const command = resolveWranglerCommand(root, subcommand); + const result = spawnSync(command, { + cwd: opts?.cwd ?? root, + shell: true, + encoding: 'utf-8', + env: provisionEnv(options?.accountId), + stdio: ['ignore', 'pipe', 'pipe'], + }); + return { + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + status: result.status ?? (result.error ? 1 : 0), + }; + }; +} + +/** Parse a wrangler `--json` stdout into an array, tolerating noise/failure. */ +function parseJsonArray(stdout: string): Array> { + const value = parseJsonLoose(stdout); + return Array.isArray(value) ? (value.filter(isRecord) as Array>) : []; +} + +/** Parse a wrangler `--json` stdout into an object, tolerating noise/failure. */ +function parseJsonObject(stdout: string): Record | undefined { + const value = parseJsonLoose(stdout); + return isRecord(value) ? value : undefined; +} + +/** + * wrangler may print human banners before the JSON payload; slice from the + * first `{`/`[` so a leading banner does not break `JSON.parse`. + */ +function parseJsonLoose(stdout: string): unknown { + const trimmed = stdout.trim(); + const start = trimmed.search(/[[{]/); + if (start === -1) { + return undefined; + } + try { + return JSON.parse(trimmed.slice(start)); + } catch { + return undefined; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** Read the entry in a `[{ binding }]` array whose `binding` matches. */ +function findBindingEntry(list: unknown, binding: string): Record | undefined { + if (!Array.isArray(list)) { + return undefined; + } + return list.find( + (entry): entry is Record => isRecord(entry) && entry.binding === binding, + ); +} + +/** Read + parse the root wrangler config (fresh each call — wrangler mutates it). */ +function readRootConfig(rootWranglerPath: string): Record { + if (!existsSync(rootWranglerPath)) { + return {}; + } + const errors: Array = []; + const parsed = parse(readFileSync(rootWranglerPath, 'utf-8'), errors, { + allowTrailingComma: true, + }); + return errors.length === 0 && isRecord(parsed) ? parsed : {}; +} + +// --- wrangler output extractors (one place to align field names with wrangler) --- + +/** `wrangler d1 list --json` / `d1 info --json` → `{ id, name }`. */ +function extractD1( + entry: Record | undefined, +): { id: string; name?: string } | undefined { + if (!entry) { + return undefined; + } + const id = entry.uuid ?? entry.database_id; + if (typeof id !== 'string' || id.length === 0) { + return undefined; + } + return { id, name: typeof entry.name === 'string' ? entry.name : undefined }; +} + +/** `wrangler kv namespace list` → `[{ id, title }]`. */ +function extractKvMatches(stdout: string, title: string): Array<{ id: string; title: string }> { + return parseJsonArray(stdout) + .filter((ns) => ns.title === title) + .map((ns) => ({ id: String(ns.id ?? ''), title })) + .filter((ns) => ns.id.length > 0); +} + +/** True when a `d1 create` failure means the name is already taken (code 7502). */ +function isAlreadyExistsError(result: RunWranglerResult): boolean { + const text = `${result.stdout}\n${result.stderr}`; + return text.includes('7502') || /already exists/i.test(text); +} + +/** + * True when a failing ` info/get` means the resource simply does NOT exist + * (safe to create), vs. a different failure (auth/network) we must surface + * instead of blindly creating. wrangler's `getQueue` throws `Queue "x" does not + * exist`; the R2/other get-by-name paths throw comparable not-found errors. + */ +function isMissingResourceError(result: RunWranglerResult): boolean { + const text = `${result.stdout}\n${result.stderr}`; + return /does not exist|not found|could not be found|no such/i.test(text); +} + +/** + * Ensure every resource in `desired` exists in the user's own Cloudflare account, + * creating the missing ones by shelling out to `wrangler` and letting + * `wrangler create --update-config` write real ids into the root config. + * For resources that already exist (adopt path) — where `create` cannot run — + * the real id is captured from `wrangler list/info` and written through the + * Task 3 writer's adopt fields. Pure provisioning: NO build, deploy, or + * drift-check. Idempotent + read-first: a binding already pointing at a real id + * is left untouched. + */ +export async function provisionCloudflareResources( + root: string, + rootWranglerPath: string, + desired: DesiredResourceSet, + opts?: { + runWrangler?: RunWrangler; + accountId?: string; + log?: ProvisionLogger; + }, +): Promise { + await Promise.resolve(); + const runWrangler = + opts?.runWrangler ?? createDefaultRunWrangler(root, { accountId: opts?.accountId }); + const log = opts?.log ?? defaultLogger; + const resources: Array = []; + let configPath = rootWranglerPath; + const write = (patch: Parameters[1]): void => { + configPath = writeProvisionedWranglerConfig(root, patch).configPath; + }; + + // --- D1 ------------------------------------------------------------------ + if (desired.d1) { + const { binding, name } = desired.d1; + const entry = findBindingEntry(readRootConfig(rootWranglerPath).d1_databases, binding); + if (entry && isRealValue(entry.database_id)) { + resources.push({ kind: 'd1', binding, name, action: 'reused', id: entry.database_id }); + } else { + const existing = extractD1( + parseJsonArray(runWrangler('d1 list --json', { captureJson: true }).stdout).find( + (db) => db.name === name, + ), + ); + if (existing) { + log.info(`adopted existing D1 database ${name} (${existing.id})`); + write({ + d1: { binding, databaseName: name, databaseId: existing.id }, + d1MigrationsDir: { binding, dir: D1_MIGRATIONS_DIR }, + }); + resources.push({ kind: 'd1', binding, name, action: 'adopted', id: existing.id }); + } else { + const created = runWrangler( + `d1 create ${name} --binding ${binding} --update-config -c ${rootWranglerPath}`, + ); + if (created.status !== 0) { + if (isAlreadyExistsError(created)) { + // TOCTOU: created between our list and create (code 7502). Capture + // the id via `d1 info` and adopt instead of failing. + const info = extractD1( + parseJsonObject(runWrangler(`d1 info ${name} --json`, { captureJson: true }).stdout), + ); + if (!info) { + throw new Error( + `provision: "wrangler d1 create ${name}" reported the database already exists, ` + + `but its id could not be resolved via "wrangler d1 info ${name} --json".`, + ); + } + log.info(`adopted existing D1 database ${name} (${info.id})`); + write({ + d1: { binding, databaseName: name, databaseId: info.id }, + d1MigrationsDir: { binding, dir: D1_MIGRATIONS_DIR }, + }); + resources.push({ kind: 'd1', binding, name, action: 'adopted', id: info.id }); + } else { + throw new Error( + `provision: "wrangler d1 create ${name}" failed: ${created.stderr || created.stdout}`, + ); + } + } else { + // wrangler wrote database_id/database_name via --update-config; fill + // the migrations_dir it does not. + write({ d1MigrationsDir: { binding, dir: D1_MIGRATIONS_DIR } }); + resources.push({ kind: 'd1', binding, name, action: 'created' }); + } + } + } + } + + // --- KV ------------------------------------------------------------------ + for (const { binding, title } of desired.kvNamespaces) { + const entry = findBindingEntry(readRootConfig(rootWranglerPath).kv_namespaces, binding); + if (entry && isRealValue(entry.id)) { + resources.push({ kind: 'kv', binding, name: title, action: 'reused', id: entry.id }); + continue; + } + const matches = extractKvMatches( + runWrangler('kv namespace list', { captureJson: true }).stdout, + title, + ); + if (matches.length > 0) { + // KV titles are NOT account-unique. Tie-break deterministically on the + // lexicographically-smallest id so re-runs adopt the same namespace. + const chosen = matches.reduce((min, ns) => (ns.id < min.id ? ns : min)); + if (matches.length > 1) { + log.warn( + `multiple KV namespaces titled "${title}" (${matches + .map((m) => m.id) + .join(', ')}); adopting the lexicographically-smallest id ${chosen.id}`, + ); + } + log.info(`adopted existing KV namespace ${title} (${chosen.id})`); + write({ kv: { binding, id: chosen.id } }); + resources.push({ kind: 'kv', binding, name: title, action: 'adopted', id: chosen.id }); + } else { + const created = runWrangler( + `kv namespace create ${title} --binding ${binding} --update-config -c ${rootWranglerPath}`, + ); + if (created.status !== 0) { + throw new Error( + `provision: "wrangler kv namespace create ${title}" failed: ${created.stderr || created.stdout}`, + ); + } + resources.push({ kind: 'kv', binding, name: title, action: 'created' }); + } + } + + // --- R2 ------------------------------------------------------------------ + if (desired.r2) { + const { binding, bucketName } = desired.r2; + const entry = findBindingEntry(readRootConfig(rootWranglerPath).r2_buckets, binding); + if (entry && isRealValue(entry.bucket_name)) { + resources.push({ kind: 'r2', binding, name: bucketName, action: 'reused' }); + } else if ( + runWrangler(`r2 bucket info ${bucketName} --json`, { captureJson: true }).status === 0 + ) { + // `wrangler r2 bucket list` is a human table, not JSON. Bucket names ARE + // account-unique, so probe the single bucket by name: `r2 bucket info` + // exits 0 only when the bucket exists (it throws a CF API error otherwise). + // Adopt it — write the `{binding,bucket_name}` entry via the writer. + log.info(`adopted existing R2 bucket ${bucketName}`); + write({ r2: { binding, bucketName } }); + resources.push({ kind: 'r2', binding, name: bucketName, action: 'adopted' }); + } else { + // Non-zero info → the bucket does not exist (or is unreachable); create it. + // `r2 bucket create` on an already-existing name errors instead of silently + // duplicating, so a transient info failure can never mint a second bucket — + // the create surfaces the real problem. `--update-config` upserts by binding. + const created = runWrangler( + `r2 bucket create ${bucketName} --binding ${binding} --update-config -c ${rootWranglerPath}`, + ); + if (created.status !== 0) { + throw new Error( + `provision: "wrangler r2 bucket create ${bucketName}" failed: ${created.stderr || created.stdout}`, + ); + } + resources.push({ kind: 'r2', binding, name: bucketName, action: 'created' }); + } + } + + // --- Hyperdrive ---------------------------------------------------------- + // wrangler 4.107 has NO machine-readable Hyperdrive list (`hyperdrive list` + // prints a cli-table3 box table; there is no `--json` and no get-by-NAME — + // `hyperdrive get` takes an id). So account-list adoption is not reliably + // possible; idempotency comes from read-first on the config (where the id + // `wrangler hyperdrive create --update-config` wrote already lives) plus + // create otherwise. The writer's `hyperdrive` adopt field stays available for + // Task 6, which can supply a known id from its own preflight. + if (desired.hyperdrive) { + const { binding, name, connectionString } = desired.hyperdrive; + const entry = findBindingEntry(readRootConfig(rootWranglerPath).hyperdrive, binding); + if (entry && isRealValue(entry.id)) { + resources.push({ kind: 'hyperdrive', binding, name, action: 'reused', id: entry.id }); + } else if (!connectionString) { + // CI-safe: never prompt. Task 6/preflight is responsible for obtaining a + // connection string; without one we cannot create the config. + const reason = 'no connection string provided'; + log.warn(`skipping Hyperdrive config ${name}: ${reason}`); + resources.push({ kind: 'hyperdrive', binding, name, action: 'skipped', reason }); + } else { + const created = runWrangler( + `hyperdrive create ${name} --connection-string=${connectionString} --binding ${binding} --update-config -c ${rootWranglerPath}`, + ); + if (created.status !== 0) { + throw new Error( + `provision: "wrangler hyperdrive create ${name}" failed: ${created.stderr || created.stdout}`, + ); + } + resources.push({ kind: 'hyperdrive', binding, name, action: 'created' }); + } + } + + // --- Queues (resource only; NEVER writes queue config entries) ----------- + // `wrangler queues list` is a human table, not JSON, and `queues create` has + // no `--update-config`. So probe each queue by name: `queues info` exits + // non-zero (`Queue "x" does not exist`) when absent. Task 4 only ensures the + // queue RESOURCE exists — binding the producers/consumers into the config is + // app-class-aware and belongs to Task 6. + for (const name of desired.queues) { + const info = runWrangler(`queues info ${name}`); + if (info.status === 0) { + resources.push({ kind: 'queue', name, action: 'reused' }); + continue; + } + if (!isMissingResourceError(info)) { + throw new Error( + `provision: "wrangler queues info ${name}" failed: ${info.stderr || info.stdout}`, + ); + } + const created = runWrangler(`queues create ${name}`); + if (created.status !== 0) { + throw new Error( + `provision: "wrangler queues create ${name}" failed: ${created.stderr || created.stdout}`, + ); + } + resources.push({ kind: 'queue', name, action: 'created' }); + } + + // --- account_id ---------------------------------------------------------- + let accountIdWritten = false; + if (opts?.accountId) { + write({ accountId: opts.accountId }); + accountIdWritten = true; + } + + return { resources, configPath, accountIdWritten }; +} diff --git a/packages/void/src/cli/wrangler.ts b/packages/void/src/cli/wrangler.ts index 405fe4cfa..ac1257378 100644 --- a/packages/void/src/cli/wrangler.ts +++ b/packages/void/src/cli/wrangler.ts @@ -748,6 +748,22 @@ export type ProvisionedWranglerPatch = { * only fills its `migrations_dir`. */ d1MigrationsDir?: { binding: string; dir: string }; + /** + * ADOPT an already-existing resource: write its REAL id/name into the config + * entry matched by `binding`, creating the entry (and its array) if absent. + * + * These exist because `wrangler create --update-config` — which is what + * writes the id on the CREATE path — cannot run when the resource already + * exists in the account (create errors on a name collision). So on the adopt + * path the provisioner captures the id via `wrangler list/info --json` + * and routes it through here, keeping every comment-preserving jsonc write in + * this one writer. Each is an idempotent, comment-preserving property upsert + * (byte-identical on re-run) that never touches sibling fields. + */ + d1?: { binding: string; databaseName: string; databaseId: string }; + kv?: { binding: string; id: string }; + r2?: { binding: string; bucketName: string }; + hyperdrive?: { binding: string; id: string }; /** * Queue producer/consumer bindings, upserted into `queues.producers[]` / * `queues.consumers[]` idempotently, keyed by each section's wrangler identity @@ -877,12 +893,64 @@ export function writeProvisionedWranglerConfig( set(['compatibility_date'], patch.compatibilityDate); } + // Adopt fields — write the REAL id/name of an already-existing resource into + // the binding's config entry (create the array + entry if absent), keyed by + // `binding`. Runs BEFORE `migrations_dir` so an adopt that CREATES a D1 entry + // is visible to the `migrations_dir` upsert below (which re-parses `text`). + // Each is an idempotent property upsert: a matched entry has only the supplied + // props set (siblings + comments preserved), a missing one is appended. + const upsertAdoptedBinding = ( + field: string, + binding: string, + properties: Record, + ): void => { + const rawList = parseJsonc(text)[field]; + const currentList = Array.isArray(rawList) ? [...rawList] : []; + const index = currentList.findIndex((entry) => isRecord(entry) && entry.binding === binding); + if (index >= 0) { + const existing = isRecord(currentList[index]) ? currentList[index] : {}; + for (const [key, value] of Object.entries(properties)) { + if (!jsonEqual(existing[key], value)) { + set([field, index, key], value); + } + } + } else { + text = applyMemberEdit( + text, + [field, currentList.length], + { binding, ...properties }, + { + formattingOptions, + isArrayInsertion: true, + }, + ); + } + }; + if (patch.d1) { + upsertAdoptedBinding('d1_databases', patch.d1.binding, { + database_name: patch.d1.databaseName, + database_id: patch.d1.databaseId, + }); + } + if (patch.kv) { + upsertAdoptedBinding('kv_namespaces', patch.kv.binding, { id: patch.kv.id }); + } + if (patch.r2) { + upsertAdoptedBinding('r2_buckets', patch.r2.binding, { bucket_name: patch.r2.bucketName }); + } + if (patch.hyperdrive) { + upsertAdoptedBinding('hyperdrive', patch.hyperdrive.binding, { id: patch.hyperdrive.id }); + } + // migrations_dir — upsert onto the D1 entry matched by binding. Indices are // stable because we only set a property (never insert/reorder array entries). + // Re-parse `text` (not the initial `parsed`) so a D1 entry an adopt just + // created above is matched here too. let d1MigrationsDirSkipped = false; if (patch.d1MigrationsDir) { - const d1Entries = Array.isArray(parsed.d1_databases) - ? (parsed.d1_databases as Array<{ binding?: unknown; migrations_pattern?: unknown }>) + const currentD1 = parseJsonc(text).d1_databases; + const d1Entries = Array.isArray(currentD1) + ? (currentD1 as Array<{ binding?: unknown; migrations_pattern?: unknown }>) : []; const index = d1Entries.findIndex((entry) => entry.binding === patch.d1MigrationsDir!.binding); if (index >= 0) { diff --git a/packages/void/test/unit/provision.test.ts b/packages/void/test/unit/provision.test.ts new file mode 100644 index 000000000..133e2fb59 --- /dev/null +++ b/packages/void/test/unit/provision.test.ts @@ -0,0 +1,681 @@ +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { parse as parseJsoncRaw } from 'jsonc-parser'; +import { join } from 'pathe'; +import { afterEach, describe, expect, it } from 'vite-plus/test'; +import { + provisionCloudflareResources, + type DesiredResourceSet, + type RunWrangler, + type RunWranglerResult, +} from '../../src/cli/provision.ts'; + +const tmpDir = join(import.meta.dirname, '.tmp-provision-test'); +const wranglerPath = join(tmpDir, 'wrangler.jsonc'); + +function setup(content: string): void { + rmSync(tmpDir, { recursive: true, force: true }); + mkdirSync(tmpDir, { recursive: true }); + writeFileSync(wranglerPath, content); +} + +afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); +}); + +function readConfig(): Record { + return parseJsoncRaw(readFileSync(wranglerPath, 'utf-8')) as Record; +} + +/** A logger that swallows output (the default one prints to console). */ +const silentLog = { info: () => {}, warn: () => {} }; + +type Handler = { match: RegExp; reply: (sub: string) => RunWranglerResult }; + +/** Build an injectable runWrangler stub that records every subcommand. */ +function makeRunner(handlers: Array): { run: RunWrangler; calls: Array } { + const calls: Array = []; + const run: RunWrangler = (sub) => { + calls.push(sub); + for (const handler of handlers) { + if (handler.match.test(sub)) { + return handler.reply(sub); + } + } + return { stdout: '', stderr: '', status: 0 }; + }; + return { run, calls }; +} + +const ok = (stdout = ''): RunWranglerResult => ({ stdout, stderr: '', status: 0 }); +const fail = (stdout: string, stderr = ''): RunWranglerResult => ({ stdout, stderr, status: 1 }); + +/** Mimic `wrangler create --update-config`: upsert a binding entry by binding. */ +function simulateUpdateConfig(field: string, entry: Record): void { + const cfg = readConfig(); + const list = Array.isArray(cfg[field]) ? (cfg[field] as Array>) : []; + const index = list.findIndex((e) => e.binding === entry.binding); + if (index >= 0) { + list[index] = { ...list[index], ...entry }; + } else { + list.push(entry); + } + cfg[field] = list; + writeFileSync(wranglerPath, `${JSON.stringify(cfg, null, 2)}\n`); +} + +const emptyDesired: DesiredResourceSet = { kvNamespaces: [], queues: [] }; + +describe('provisionCloudflareResources — read-first', () => { + it('skips D1 entirely when the binding already has a real database_id (records reused, no wrangler call)', async () => { + setup( + JSON.stringify({ + d1_databases: [{ binding: 'DB', database_name: 'myapp-db', database_id: 'real-uuid-1' }], + }), + ); + const { run, calls } = makeRunner([]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, + { runWrangler: run, log: silentLog }, + ); + expect(calls).toEqual([]); + expect(result.resources).toEqual([ + { kind: 'd1', binding: 'DB', name: 'myapp-db', action: 'reused', id: 'real-uuid-1' }, + ]); + }); + + it('does NOT treat a placeholder database_id ("local") as real — proceeds to provision', async () => { + setup( + JSON.stringify({ + d1_databases: [{ binding: 'DB', database_name: 'default', database_id: 'local' }], + }), + ); + const { run, calls } = makeRunner([ + { match: /^d1 list --json$/, reply: () => ok('[]') }, + { + match: /^d1 create /, + reply: () => { + simulateUpdateConfig('d1_databases', { + binding: 'DB', + database_name: 'myapp-db', + database_id: 'created-uuid', + }); + return ok(); + }, + }, + ]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, + { runWrangler: run, log: silentLog }, + ); + expect(calls[0]).toBe('d1 list --json'); + expect(result.resources[0].action).toBe('created'); + }); +}); + +describe('provisionCloudflareResources — D1', () => { + it('creates a missing D1 with --update-config then writes migrations_dir', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { match: /^d1 list --json$/, reply: () => ok('[]') }, + { + match: /^d1 create /, + reply: () => { + simulateUpdateConfig('d1_databases', { + binding: 'DB', + database_name: 'myapp-db', + database_id: 'created-uuid', + }); + return ok(); + }, + }, + ]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, + { runWrangler: run, log: silentLog }, + ); + expect(calls).toEqual([ + 'd1 list --json', + `d1 create myapp-db --binding DB --update-config -c ${wranglerPath}`, + ]); + const d1 = readConfig().d1_databases as Array>; + expect(d1).toEqual([ + { + binding: 'DB', + database_name: 'myapp-db', + database_id: 'created-uuid', + migrations_dir: './db/migrations', + }, + ]); + expect(result.resources).toEqual([ + { kind: 'd1', binding: 'DB', name: 'myapp-db', action: 'created' }, + ]); + }); + + it('adopts an existing D1 found in the account (list hit) — no create, writes id + migrations_dir', async () => { + setup( + JSON.stringify({ + d1_databases: [{ binding: 'DB', database_name: 'default', database_id: 'local' }], + }), + ); + const { run, calls } = makeRunner([ + { + match: /^d1 list --json$/, + reply: () => ok(JSON.stringify([{ uuid: 'existing-uuid', name: 'myapp-db' }])), + }, + ]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, + { runWrangler: run, log: silentLog }, + ); + expect(calls).toEqual(['d1 list --json']); + expect(calls.some((c) => c.startsWith('d1 create'))).toBe(false); + const d1 = readConfig().d1_databases as Array>; + expect(d1).toEqual([ + { + binding: 'DB', + database_name: 'myapp-db', + database_id: 'existing-uuid', + migrations_dir: './db/migrations', + }, + ]); + expect(result.resources).toEqual([ + { kind: 'd1', binding: 'DB', name: 'myapp-db', action: 'adopted', id: 'existing-uuid' }, + ]); + }); + + it('treats a create-time 7502 (already exists) as adopt — captures the id via d1 info', async () => { + setup( + JSON.stringify({ + d1_databases: [{ binding: 'DB', database_name: 'default', database_id: 'local' }], + }), + ); + const { run, calls } = makeRunner([ + { match: /^d1 list --json$/, reply: () => ok('[]') }, + { + match: /^d1 create /, + reply: () => fail('', 'X [ERROR] A database with that name already exists [code: 7502]'), + }, + { + match: /^d1 info myapp-db --json$/, + reply: () => ok(JSON.stringify({ uuid: 'raced-uuid', name: 'myapp-db' })), + }, + ]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, + { runWrangler: run, log: silentLog }, + ); + expect(calls).toEqual([ + 'd1 list --json', + `d1 create myapp-db --binding DB --update-config -c ${wranglerPath}`, + 'd1 info myapp-db --json', + ]); + const d1 = readConfig().d1_databases as Array>; + expect(d1[0]).toMatchObject({ binding: 'DB', database_id: 'raced-uuid' }); + expect(result.resources[0]).toEqual({ + kind: 'd1', + binding: 'DB', + name: 'myapp-db', + action: 'adopted', + id: 'raced-uuid', + }); + }); + + it('throws on a non-7502 create failure', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run } = makeRunner([ + { match: /^d1 list --json$/, reply: () => ok('[]') }, + { + match: /^d1 create /, + reply: () => fail('', 'X [ERROR] Authentication error [code: 10000]'), + }, + ]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, + { runWrangler: run, log: silentLog }, + ), + ).rejects.toThrow(/d1 create myapp-db.*failed/s); + }); +}); + +describe('provisionCloudflareResources — KV', () => { + it('adopts when the namespace list shows a title match (writes id, no create)', async () => { + setup(JSON.stringify({ kv_namespaces: [{ binding: 'KV', id: 'local' }] })); + const { run, calls } = makeRunner([ + { + match: /^kv namespace list$/, + reply: () => ok(JSON.stringify([{ id: 'real-kv-id', title: 'myapp-kv' }])), + }, + ]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, kvNamespaces: [{ binding: 'KV', title: 'myapp-kv' }] }, + { runWrangler: run, log: silentLog }, + ); + expect(calls).toEqual(['kv namespace list']); + const kv = readConfig().kv_namespaces as Array>; + expect(kv).toEqual([{ binding: 'KV', id: 'real-kv-id' }]); + expect(result.resources).toEqual([ + { kind: 'kv', binding: 'KV', name: 'myapp-kv', action: 'adopted', id: 'real-kv-id' }, + ]); + }); + + it('creates when the namespace list is empty', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { match: /^kv namespace list$/, reply: () => ok('[]') }, + { + match: /^kv namespace create /, + reply: () => { + simulateUpdateConfig('kv_namespaces', { binding: 'ISR_CACHE', id: 'new-kv-id' }); + return ok(); + }, + }, + ]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, kvNamespaces: [{ binding: 'ISR_CACHE', title: 'myapp-isr' }] }, + { runWrangler: run, log: silentLog }, + ); + expect(calls).toEqual([ + 'kv namespace list', + `kv namespace create myapp-isr --binding ISR_CACHE --update-config -c ${wranglerPath}`, + ]); + expect(result.resources).toEqual([ + { kind: 'kv', binding: 'ISR_CACHE', name: 'myapp-isr', action: 'created' }, + ]); + }); + + it('picks the lexicographically-smallest id and warns on multiple title matches', async () => { + setup(JSON.stringify({ name: 'app' })); + const warnings: Array = []; + const { run } = makeRunner([ + { + match: /^kv namespace list$/, + reply: () => + ok( + JSON.stringify([ + { id: 'zzz', title: 'myapp-kv' }, + { id: 'aaa', title: 'myapp-kv' }, + { id: 'mmm', title: 'other' }, + ]), + ), + }, + ]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, kvNamespaces: [{ binding: 'KV', title: 'myapp-kv' }] }, + { runWrangler: run, log: { info: () => {}, warn: (m) => warnings.push(m) } }, + ); + expect(result.resources[0]).toMatchObject({ action: 'adopted', id: 'aaa' }); + expect((readConfig().kv_namespaces as Array>)[0].id).toBe('aaa'); + expect(warnings.some((w) => w.includes('multiple KV namespaces') && w.includes('aaa'))).toBe( + true, + ); + }); + + it('provisions each KV binding independently (ISR_CACHE + app KV)', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { match: /^kv namespace list$/, reply: () => ok('[]') }, + { + match: /^kv namespace create /, + reply: (sub) => { + const binding = sub.includes('ISR_CACHE') ? 'ISR_CACHE' : 'KV'; + simulateUpdateConfig('kv_namespaces', { binding, id: `${binding}-id` }); + return ok(); + }, + }, + ]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + kvNamespaces: [ + { binding: 'KV', title: 'myapp-kv' }, + { binding: 'ISR_CACHE', title: 'myapp-isr' }, + ], + }, + { runWrangler: run, log: silentLog }, + ); + expect(result.resources.map((r) => r.action)).toEqual(['created', 'created']); + const kv = readConfig().kv_namespaces as Array>; + expect(kv.map((e) => e.binding).sort()).toEqual(['ISR_CACHE', 'KV']); + expect(calls.filter((c) => c.startsWith('kv namespace create'))).toHaveLength(2); + }); +}); + +describe('provisionCloudflareResources — account_id', () => { + it('writes top-level account_id via the writer when provided', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run } = makeRunner([]); + const result = await provisionCloudflareResources(tmpDir, wranglerPath, emptyDesired, { + runWrangler: run, + accountId: 'acc-123', + log: silentLog, + }); + expect(readConfig().account_id).toBe('acc-123'); + expect(result.accountIdWritten).toBe(true); + }); + + it('does not write account_id when not provided', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run } = makeRunner([]); + const result = await provisionCloudflareResources(tmpDir, wranglerPath, emptyDesired, { + runWrangler: run, + log: silentLog, + }); + expect(readConfig().account_id).toBeUndefined(); + expect(result.accountIdWritten).toBe(false); + }); +}); + +describe('provisionCloudflareResources — idempotent re-run', () => { + it('a second run after adoption is a read-first no-op (byte-identical config, no wrangler calls)', async () => { + setup(JSON.stringify({ kv_namespaces: [{ binding: 'KV', id: 'local' }] })); + const first = makeRunner([ + { + match: /^kv namespace list$/, + reply: () => ok(JSON.stringify([{ id: 'real-kv-id', title: 'myapp-kv' }])), + }, + ]); + const desired: DesiredResourceSet = { + ...emptyDesired, + kvNamespaces: [{ binding: 'KV', title: 'myapp-kv' }], + }; + await provisionCloudflareResources(tmpDir, wranglerPath, desired, { + runWrangler: first.run, + log: silentLog, + }); + const afterFirst = readFileSync(wranglerPath, 'utf-8'); + + const second = makeRunner([]); + const result = await provisionCloudflareResources(tmpDir, wranglerPath, desired, { + runWrangler: second.run, + log: silentLog, + }); + expect(second.calls).toEqual([]); + expect(result.resources).toEqual([ + { kind: 'kv', binding: 'KV', name: 'myapp-kv', action: 'reused', id: 'real-kv-id' }, + ]); + expect(readFileSync(wranglerPath, 'utf-8')).toBe(afterFirst); + }); +}); + +describe('provisionCloudflareResources — R2', () => { + it('reuses when the bucket_name is already real (no wrangler call)', async () => { + setup(JSON.stringify({ r2_buckets: [{ binding: 'STORAGE', bucket_name: 'myapp-uploads' }] })); + const { run, calls } = makeRunner([]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, r2: { binding: 'STORAGE', bucketName: 'myapp-uploads' } }, + { runWrangler: run, log: silentLog }, + ); + expect(calls).toEqual([]); + expect(result.resources).toEqual([ + { kind: 'r2', binding: 'STORAGE', name: 'myapp-uploads', action: 'reused' }, + ]); + }); + + it('adopts an existing bucket via `r2 bucket info` (status 0) — writes the entry, no create', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { + match: /^r2 bucket info myapp-uploads --json$/, + reply: () => ok(JSON.stringify({ name: 'myapp-uploads', created: 'x' })), + }, + ]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, r2: { binding: 'STORAGE', bucketName: 'myapp-uploads' } }, + { runWrangler: run, log: silentLog }, + ); + expect(calls).toEqual(['r2 bucket info myapp-uploads --json']); + expect(readConfig().r2_buckets).toEqual([{ binding: 'STORAGE', bucket_name: 'myapp-uploads' }]); + expect(result.resources).toEqual([ + { kind: 'r2', binding: 'STORAGE', name: 'myapp-uploads', action: 'adopted' }, + ]); + }); + + it('creates the bucket when `r2 bucket info` reports it does not exist', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { + match: /^r2 bucket info /, + reply: () => fail('', 'X [ERROR] The specified bucket does not exist'), + }, + { + match: /^r2 bucket create /, + reply: () => { + simulateUpdateConfig('r2_buckets', { + binding: 'STORAGE', + bucket_name: 'myapp-uploads', + }); + return ok(); + }, + }, + ]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, r2: { binding: 'STORAGE', bucketName: 'myapp-uploads' } }, + { runWrangler: run, log: silentLog }, + ); + expect(calls).toEqual([ + 'r2 bucket info myapp-uploads --json', + `r2 bucket create myapp-uploads --binding STORAGE --update-config -c ${wranglerPath}`, + ]); + expect(result.resources).toEqual([ + { kind: 'r2', binding: 'STORAGE', name: 'myapp-uploads', action: 'created' }, + ]); + }); + + it('surfaces a create failure (e.g. auth error) when the bucket cannot be probed or created', async () => { + // Non-zero info → attempt create; `r2 bucket create` on an existing name + // errors rather than duplicating, so a failed create always surfaces the real + // problem instead of minting a second bucket. + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { + match: /^r2 bucket info /, + reply: () => fail('', 'X [ERROR] Authentication error [code: 10000]'), + }, + { + match: /^r2 bucket create /, + reply: () => fail('', 'X [ERROR] Authentication error [code: 10000]'), + }, + ]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, r2: { binding: 'STORAGE', bucketName: 'myapp-uploads' } }, + { runWrangler: run, log: silentLog }, + ), + ).rejects.toThrow(/r2 bucket create myapp-uploads.*failed/s); + expect(calls).toEqual([ + 'r2 bucket info myapp-uploads --json', + `r2 bucket create myapp-uploads --binding STORAGE --update-config -c ${wranglerPath}`, + ]); + }); +}); + +describe('provisionCloudflareResources — Hyperdrive', () => { + it('reuses when the hyperdrive binding already has a real id (no wrangler call)', async () => { + setup(JSON.stringify({ hyperdrive: [{ binding: 'HYPERDRIVE', id: 'real-hd-id' }] })); + const { run, calls } = makeRunner([]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + hyperdrive: { binding: 'HYPERDRIVE', name: 'myapp-hd', connectionString: 'postgres://x' }, + }, + { runWrangler: run, log: silentLog }, + ); + expect(calls).toEqual([]); + expect(result.resources).toEqual([ + { + kind: 'hyperdrive', + binding: 'HYPERDRIVE', + name: 'myapp-hd', + action: 'reused', + id: 'real-hd-id', + }, + ]); + }); + + it('skips with a structured reason when no connection string is provided (never prompts)', async () => { + setup(JSON.stringify({ name: 'app' })); + const warnings: Array = []; + const { run, calls } = makeRunner([]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + hyperdrive: { binding: 'HYPERDRIVE', name: 'myapp-hd', connectionString: '' }, + }, + { runWrangler: run, log: { info: () => {}, warn: (m) => warnings.push(m) } }, + ); + expect(calls).toEqual([]); + expect(result.resources).toEqual([ + { + kind: 'hyperdrive', + binding: 'HYPERDRIVE', + name: 'myapp-hd', + action: 'skipped', + reason: 'no connection string provided', + }, + ]); + expect(warnings.some((w) => w.includes('skipping Hyperdrive'))).toBe(true); + }); + + it('creates the config with the connection string via --update-config', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { + match: /^hyperdrive create /, + reply: () => { + simulateUpdateConfig('hyperdrive', { binding: 'HYPERDRIVE', id: 'new-hd-id' }); + return ok(); + }, + }, + ]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + hyperdrive: { + binding: 'HYPERDRIVE', + name: 'myapp-hd', + connectionString: 'postgres://u:p@h/db', + }, + }, + { runWrangler: run, log: silentLog }, + ); + expect(calls).toEqual([ + `hyperdrive create myapp-hd --connection-string=postgres://u:p@h/db --binding HYPERDRIVE --update-config -c ${wranglerPath}`, + ]); + expect(result.resources).toEqual([ + { kind: 'hyperdrive', binding: 'HYPERDRIVE', name: 'myapp-hd', action: 'created' }, + ]); + }); +}); + +describe('provisionCloudflareResources — Queues (resource only)', () => { + it('reuses a queue that `queues info` finds (no create, never writes queue config)', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { match: /^queues info jobs$/, reply: () => ok('Queue Name: jobs') }, + ]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, queues: ['jobs'] }, + { runWrangler: run, log: silentLog }, + ); + expect(calls).toEqual(['queues info jobs']); + expect(result.resources).toEqual([{ kind: 'queue', name: 'jobs', action: 'reused' }]); + // Task 4 must NOT write queue producer/consumer entries into the config. + expect(readConfig().queues).toBeUndefined(); + }); + + it('creates a queue that `queues info` reports missing; never writes queue config entries', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { + match: /^queues info emails$/, + reply: () => fail('', 'X [ERROR] Queue "emails" does not exist.'), + }, + { match: /^queues create emails$/, reply: () => ok('Created queue emails') }, + ]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, queues: ['emails'] }, + { runWrangler: run, log: silentLog }, + ); + expect(calls).toEqual(['queues info emails', 'queues create emails']); + expect(result.resources).toEqual([{ kind: 'queue', name: 'emails', action: 'created' }]); + expect(readConfig().queues).toBeUndefined(); + }); + + it('handles a mix of existing and missing queues', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { match: /^queues info jobs$/, reply: () => ok('Queue Name: jobs') }, + { match: /^queues info emails$/, reply: () => fail('', 'Queue "emails" does not exist.') }, + { match: /^queues create emails$/, reply: () => ok() }, + ]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, queues: ['jobs', 'emails'] }, + { runWrangler: run, log: silentLog }, + ); + expect(calls).toEqual(['queues info jobs', 'queues info emails', 'queues create emails']); + expect(result.resources).toEqual([ + { kind: 'queue', name: 'jobs', action: 'reused' }, + { kind: 'queue', name: 'emails', action: 'created' }, + ]); + }); + + it('throws (does NOT create) when `queues info` fails for a non-missing reason', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { + match: /^queues info jobs$/, + reply: () => fail('', 'X [ERROR] Authentication error [code: 10000]'), + }, + ]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, queues: ['jobs'] }, + { runWrangler: run, log: silentLog }, + ), + ).rejects.toThrow(/queues info jobs.*failed/s); + expect(calls.some((c) => c.startsWith('queues create'))).toBe(false); + }); +}); diff --git a/packages/void/test/unit/wrangler.test.ts b/packages/void/test/unit/wrangler.test.ts index 220a96741..3891f497b 100644 --- a/packages/void/test/unit/wrangler.test.ts +++ b/packages/void/test/unit/wrangler.test.ts @@ -1967,4 +1967,191 @@ describe('writeProvisionedWranglerConfig', () => { expect(second.modified).toBe(false); expect(readRaw('wrangler.jsonc')).toBe(raw); }); + + // Adopt fields — write the REAL id/name of an already-existing resource into + // the binding's entry (the CREATE path can't, since `wrangler create + // --update-config` errors on a name collision). Upsert-existing + upsert-new + + // idempotent-rerun + comment-preserved, for each of d1/kv/r2/hyperdrive. + + it('d1 adopt: overwrites a placeholder database_id/database_name in place, keeps comments, idempotent', () => { + setup( + 'wrangler.jsonc', + `{ + // Void manages this file — comments must survive adoption. + "d1_databases": [ + { "binding": "DB", "database_name": "default", "database_id": "local" } + ] +}`, + ); + + const patch = { + d1: { binding: 'DB', databaseName: 'myapp-db', databaseId: 'real-uuid-123' }, + }; + const first = writeProvisionedWranglerConfig(tmpDir, patch); + expect(first.modified).toBe(true); + + const raw = readRaw('wrangler.jsonc'); + expect(raw).toContain('// Void manages this file'); + + const d1 = parseRaw('wrangler.jsonc').d1_databases as Array>; + // No duplicate entry — the placeholder was updated in place. + expect(d1).toHaveLength(1); + expect(d1[0]).toEqual({ + binding: 'DB', + database_name: 'myapp-db', + database_id: 'real-uuid-123', + }); + + // Idempotent — a second run is byte-identical. + const afterFirst = raw; + const second = writeProvisionedWranglerConfig(tmpDir, patch); + expect(second.modified).toBe(false); + expect(readRaw('wrangler.jsonc')).toBe(afterFirst); + }); + + it('d1 adopt: creates the d1_databases entry when the binding is absent', () => { + setup('wrangler.jsonc', '{ "name": "app" }'); + const result = writeProvisionedWranglerConfig(tmpDir, { + d1: { binding: 'DB', databaseName: 'myapp-db', databaseId: 'real-uuid-123' }, + }); + expect(result.modified).toBe(true); + const d1 = parseRaw('wrangler.jsonc').d1_databases as Array>; + expect(d1).toHaveLength(1); + expect(d1[0]).toEqual({ + binding: 'DB', + database_name: 'myapp-db', + database_id: 'real-uuid-123', + }); + }); + + it('d1 adopt composes with d1MigrationsDir on the same binding (the real adopt path)', () => { + // The adopt path passes both: capture the real id AND set migrations_dir. Even + // when the D1 entry is absent, adopt creates it and migrations_dir lands on it. + setup('wrangler.jsonc', '{ "name": "app" }'); + const patch = { + d1: { binding: 'DB', databaseName: 'myapp-db', databaseId: 'real-uuid-123' }, + d1MigrationsDir: { binding: 'DB', dir: './db/migrations' }, + }; + const first = writeProvisionedWranglerConfig(tmpDir, patch); + expect(first.modified).toBe(true); + expect(first.d1MigrationsDirSkipped).toBe(false); + + const d1 = parseRaw('wrangler.jsonc').d1_databases as Array>; + expect(d1).toHaveLength(1); + expect(d1[0]).toMatchObject({ + binding: 'DB', + database_name: 'myapp-db', + database_id: 'real-uuid-123', + migrations_dir: './db/migrations', + }); + + // Idempotent. + const afterFirst = readRaw('wrangler.jsonc'); + const second = writeProvisionedWranglerConfig(tmpDir, patch); + expect(second.modified).toBe(false); + expect(readRaw('wrangler.jsonc')).toBe(afterFirst); + }); + + it('kv adopt: overwrites a placeholder id in place, keeps comments, idempotent', () => { + setup( + 'wrangler.jsonc', + `{ + // comment must survive + "kv_namespaces": [ + { "binding": "ISR_CACHE", "id": "local" } + ] +}`, + ); + const patch = { kv: { binding: 'ISR_CACHE', id: 'realkvid456' } }; + const first = writeProvisionedWranglerConfig(tmpDir, patch); + expect(first.modified).toBe(true); + expect(readRaw('wrangler.jsonc')).toContain('// comment must survive'); + + const kv = parseRaw('wrangler.jsonc').kv_namespaces as Array>; + expect(kv).toHaveLength(1); + expect(kv[0]).toEqual({ binding: 'ISR_CACHE', id: 'realkvid456' }); + + const afterFirst = readRaw('wrangler.jsonc'); + const second = writeProvisionedWranglerConfig(tmpDir, patch); + expect(second.modified).toBe(false); + expect(readRaw('wrangler.jsonc')).toBe(afterFirst); + }); + + it('kv adopt: creates the kv_namespaces entry when the binding is absent', () => { + setup('wrangler.jsonc', '{ "name": "app" }'); + const result = writeProvisionedWranglerConfig(tmpDir, { + kv: { binding: 'KV', id: 'realkvid456' }, + }); + expect(result.modified).toBe(true); + const kv = parseRaw('wrangler.jsonc').kv_namespaces as Array>; + expect(kv).toEqual([{ binding: 'KV', id: 'realkvid456' }]); + }); + + it('r2 adopt: overwrites a placeholder bucket_name in place, keeps comments, idempotent', () => { + setup( + 'wrangler.jsonc', + `{ + // comment must survive + "r2_buckets": [ + { "binding": "STORAGE", "bucket_name": "default" } + ] +}`, + ); + const patch = { r2: { binding: 'STORAGE', bucketName: 'myapp-uploads' } }; + const first = writeProvisionedWranglerConfig(tmpDir, patch); + expect(first.modified).toBe(true); + expect(readRaw('wrangler.jsonc')).toContain('// comment must survive'); + + const r2 = parseRaw('wrangler.jsonc').r2_buckets as Array>; + expect(r2).toEqual([{ binding: 'STORAGE', bucket_name: 'myapp-uploads' }]); + + const afterFirst = readRaw('wrangler.jsonc'); + const second = writeProvisionedWranglerConfig(tmpDir, patch); + expect(second.modified).toBe(false); + expect(readRaw('wrangler.jsonc')).toBe(afterFirst); + }); + + it('r2 adopt: creates the r2_buckets entry when the binding is absent', () => { + setup('wrangler.jsonc', '{ "name": "app" }'); + const result = writeProvisionedWranglerConfig(tmpDir, { + r2: { binding: 'STORAGE', bucketName: 'myapp-uploads' }, + }); + expect(result.modified).toBe(true); + const r2 = parseRaw('wrangler.jsonc').r2_buckets as Array>; + expect(r2).toEqual([{ binding: 'STORAGE', bucket_name: 'myapp-uploads' }]); + }); + + it('hyperdrive adopt: overwrites the id in place, keeps comments, idempotent', () => { + setup( + 'wrangler.jsonc', + `{ + // comment must survive + "hyperdrive": [ + { "binding": "HYPERDRIVE", "id": "local" } + ] +}`, + ); + const patch = { hyperdrive: { binding: 'HYPERDRIVE', id: 'realhyperid789' } }; + const first = writeProvisionedWranglerConfig(tmpDir, patch); + expect(first.modified).toBe(true); + expect(readRaw('wrangler.jsonc')).toContain('// comment must survive'); + + const hd = parseRaw('wrangler.jsonc').hyperdrive as Array>; + expect(hd).toEqual([{ binding: 'HYPERDRIVE', id: 'realhyperid789' }]); + + const afterFirst = readRaw('wrangler.jsonc'); + const second = writeProvisionedWranglerConfig(tmpDir, patch); + expect(second.modified).toBe(false); + expect(readRaw('wrangler.jsonc')).toBe(afterFirst); + }); + + it('hyperdrive adopt: creates the hyperdrive entry when the binding is absent', () => { + setup('wrangler.jsonc', '{ "name": "app" }'); + const result = writeProvisionedWranglerConfig(tmpDir, { + hyperdrive: { binding: 'HYPERDRIVE', id: 'realhyperid789' }, + }); + expect(result.modified).toBe(true); + const hd = parseRaw('wrangler.jsonc').hyperdrive as Array>; + expect(hd).toEqual([{ binding: 'HYPERDRIVE', id: 'realhyperid789' }]); + }); }); From 4119ff946127bcc1d37be5b375147b6e68e26dfb Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 16 Jul 2026 13:35:57 +0800 Subject: [PATCH 017/128] =?UTF-8?q?fix(cli):=20harden=20Cloudflare=20provi?= =?UTF-8?q?sioner=20=E2=80=94=20argv=20seam,=20fail-closed=20checks,=20hyp?= =?UTF-8?q?erdrive=20adoption?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address 7 verified codex findings on the Task 4 provisioner: 1. argv-injection: RunWrangler seam now takes a string[] argv; default impl spawns shell:false via new resolveWranglerArgv() launcher tokens, so a Postgres connection string / spaced path is one literal arg (no shell). 2. config discovery: readRootConfig is BOM-aware + fails closed on parse error/non-object; one exact configPath is threaded through read, wrangler -c, and the writer (new writeProvisionedWranglerConfig configPath option) so reads/writes never split-brain. 3. D1 convergence: migrations_dir ensured + verified on the reused path too; d1MigrationsDirSkipped is surfaced (throws) instead of silently succeeding. 4. KV pre-check: require status 0 + schema-valid JSON array before treating a title as absent; else fail closed (no orphan duplicate namespace). 5. R2 pre-check: create only on confirmed not-found (CF 10006 / not-found message); other nonzero info failures surface instead of creating. 6. Hyperdrive: add an injectable REST existence check + adopt-by-name before the connection-string gate; ambiguous existence + conn string fails closed. 7. writer adopt fields: uphold worker-wide binding uniqueness — fail closed on a duplicate same-binding entry and on a cross-type collision (reuses the existing collectWorkerBindingNames collector). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/cli/preset.ts | 56 ++- packages/void/src/cli/provision.ts | 415 ++++++++++++++----- packages/void/src/cli/wrangler.ts | 80 +++- packages/void/test/unit/provision.test.ts | 469 +++++++++++++++++++--- packages/void/test/unit/wrangler.test.ts | 90 +++++ 5 files changed, 948 insertions(+), 162 deletions(-) diff --git a/packages/void/src/cli/preset.ts b/packages/void/src/cli/preset.ts index 1718fa2e2..65255955b 100644 --- a/packages/void/src/cli/preset.ts +++ b/packages/void/src/cli/preset.ts @@ -1,4 +1,4 @@ -import { existsSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { join, resolve } from 'pathe'; import { readProjectPaths } from '../project-paths.ts'; import { @@ -189,6 +189,60 @@ export function resolveWranglerCommand(root: string, subcommand: string): string return resolveProjectCommand(root, `wrangler ${subcommand}`); } +/** + * Resolve the project-local `wrangler` as an ARGV LAUNCHER — an array of tokens + * `[executable, ...prefixArgs]` meant to be spawned with `shell: false` and the + * real wrangler subcommand appended as further DISCRETE argv elements. + * + * Unlike {@link resolveWranglerCommand} (which returns a shell-quoted STRING for + * `shell: true` callers), this never builds a shell command line, so untrusted + * values — resource names, a Postgres connection string riddled with `&`/`$`/`;`, + * a config path containing spaces — can be passed as their own argv elements and + * are IMMUNE to shell metacharacter injection/splitting. + * + * Resolution order: + * 1. The locally-installed wrangler's JS bin run with the current Node + * (`[process.execPath, ]`). This is + * shell-free AND cross-platform: it sidesteps the Windows `.cmd`/`.ps1` + * shim (which Node cannot spawn with `shell: false`) entirely, so there is + * never a shim to quote or route through `cmd.exe`. + * 2. Fallback to the `node_modules/.bin/wrangler` shim when the package layout + * is non-standard but the shim exists (directly executable on POSIX). + * 3. Fallback to the bare `wrangler` name (resolved via PATH by the OS). + */ +export function resolveWranglerArgv(root: string): Array { + const jsBin = resolveLocalWranglerJsBin(root); + if (jsBin) { + return [process.execPath, jsBin]; + } + return [localBin(root, 'wrangler')]; +} + +/** + * Absolute path to the locally-installed wrangler JS entry (from its + * `package.json` `bin.wrangler`), or `undefined` when wrangler is not installed + * under `root/node_modules` or its manifest is unreadable. + */ +function resolveLocalWranglerJsBin(root: string): string | undefined { + const pkgPath = join(root, 'node_modules', 'wrangler', 'package.json'); + if (!existsSync(pkgPath)) { + return undefined; + } + try { + const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { + bin?: string | Record; + }; + const rel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.wrangler; + if (!rel) { + return undefined; + } + const jsBin = join(root, 'node_modules', 'wrangler', rel); + return existsSync(jsBin) ? jsBin : undefined; + } catch { + return undefined; + } +} + /** * Format a user-facing command string, rewriting `vite` to `vp` when the * project depends on Vite+ without expanding to an absolute local bin path. diff --git a/packages/void/src/cli/provision.ts b/packages/void/src/cli/provision.ts index 99af33904..46abcdf28 100644 --- a/packages/void/src/cli/provision.ts +++ b/packages/void/src/cli/provision.ts @@ -2,8 +2,8 @@ import { spawnSync } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; import { parse, type ParseError } from 'jsonc-parser'; import { voidLog, voidWarn } from '../log.ts'; -import { resolveWranglerCommand } from './preset.ts'; -import { writeProvisionedWranglerConfig } from './wrangler.ts'; +import { resolveWranglerArgv } from './preset.ts'; +import { writeProvisionedWranglerConfig, type WriteProvisionedWranglerResult } from './wrangler.ts'; /** * The set of Cloudflare resources an app needs, computed by the deploy @@ -31,16 +31,38 @@ export interface DesiredResourceSet { export type RunWranglerResult = { stdout: string; stderr: string; status: number }; /** - * Seam over shelling out to the project's `wrangler`. The default - * implementation ({@link createDefaultRunWrangler}) wraps `spawnSync`; unit - * tests inject a stub so no real wrangler runs. `subcommand` is the part AFTER - * `wrangler ` (e.g. `d1 list --json`). + * Seam over shelling out to the project's `wrangler`. The default implementation + * ({@link createDefaultRunWrangler}) wraps `spawnSync` with `shell: false`; unit + * tests inject a stub so no real wrangler runs. + * + * `args` is the wrangler ARGV after the launcher — one array element per token, + * e.g. `['d1', 'list', '--json']` or + * `['hyperdrive', 'create', name, '--connection-string=' + conn, ...]`. Every + * untrusted value (resource name, connection string, config path) is its OWN + * array element and is passed literally to the process (no shell), so shell + * metacharacters (`&`, `$`, `;`, spaces) can never split, inject, or drop args. */ export type RunWrangler = ( - subcommand: string, + args: Array, opts?: { cwd?: string; captureJson?: boolean }, ) => RunWranglerResult; +/** A Cloudflare Hyperdrive config as returned by the account-list existence check. */ +export interface HyperdriveConfig { + id: string; + name: string; +} + +/** + * Seam over listing the account's Hyperdrive configs (wrangler 4.107 has NO + * machine-readable `hyperdrive list` and no get-by-name, so existence is checked + * via the Cloudflare REST API). The default implementation + * ({@link createDefaultListHyperdriveConfigs}) calls + * `GET /accounts/{id}/hyperdrive/configs`; unit tests inject a stub so no real + * network call runs. + */ +export type ListHyperdriveConfigs = () => Promise>; + /** Per-resource outcome, so the orchestrator can log what happened. */ export interface ProvisionOutcome { kind: 'd1' | 'kv' | 'r2' | 'hyperdrive' | 'queue'; @@ -71,6 +93,9 @@ export interface ProvisionLogger { /** Where Void keeps D1 migrations — written as `migrations_dir` on the D1 entry. */ const D1_MIGRATIONS_DIR = './db/migrations'; +/** UTF-8 byte-order mark. Node's utf-8 read preserves a leading one. */ +const UTF8_BOM = ''; + /** * Values wrangler/Void write as placeholders during local dev * (`syncWranglerBindings`): a D1 `database_id: 'local'`, a KV `id: 'local'`, an @@ -106,23 +131,26 @@ function provisionEnv(accountId?: string): NodeJS.ProcessEnv { } /** - * Default `runWrangler`: resolves the project-local wrangler via Task 2's - * {@link resolveWranglerCommand} and runs it with `spawnSync`, capturing stdout - * (for `--json` parsing) and stderr (for error/collision detection). Never - * called in unit tests — those inject a stub. + * Default `runWrangler`: resolves the project-local wrangler as an ARGV launcher + * via Task 2's {@link resolveWranglerArgv} and runs it with `spawnSync` and + * `shell: false`, so every argument is passed literally (no shell parsing → no + * injection). Captures stdout (for `--json` parsing) and stderr (for + * error/collision detection). Never called in unit tests — those inject a stub. */ export function createDefaultRunWrangler( root: string, options?: { accountId?: string }, ): RunWrangler { - return (subcommand, opts) => { - const command = resolveWranglerCommand(root, subcommand); - const result = spawnSync(command, { + const launcher = resolveWranglerArgv(root); + const [command, ...prefix] = launcher; + return (args, opts) => { + const result = spawnSync(command, [...prefix, ...args], { cwd: opts?.cwd ?? root, - shell: true, + shell: false, encoding: 'utf-8', env: provisionEnv(options?.accountId), stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, }); return { stdout: result.stdout ?? '', @@ -132,12 +160,60 @@ export function createDefaultRunWrangler( }; } +/** + * Default Hyperdrive existence check: `GET /accounts/{id}/hyperdrive/configs` + * with the CF API token, matched by name in the caller. Returns `undefined` when + * it cannot be built (no account id / no token) — the caller then fails closed + * before any blind create. Never called in unit tests — those inject a stub. + */ +function createDefaultListHyperdriveConfigs( + accountId?: string, + apiToken?: string, +): ListHyperdriveConfigs | undefined { + const token = apiToken ?? process.env.CLOUDFLARE_API_TOKEN; + if (!accountId || !token) { + return undefined; + } + return async () => { + const res = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/hyperdrive/configs`, + { headers: { Authorization: `Bearer ${token}` } }, + ); + if (!res.ok) { + throw new Error(`Cloudflare API returned HTTP ${res.status} listing Hyperdrive configs`); + } + const body = (await res.json()) as { result?: unknown }; + if (!isRecord(body) || !Array.isArray(body.result)) { + throw new Error('Cloudflare API returned an unexpected Hyperdrive configs payload'); + } + return body.result + .filter( + (c): c is { id: string; name: string } => + isRecord(c) && typeof c.id === 'string' && typeof c.name === 'string', + ) + .map((c) => ({ id: c.id, name: c.name })); + }; +} + /** Parse a wrangler `--json` stdout into an array, tolerating noise/failure. */ function parseJsonArray(stdout: string): Array> { const value = parseJsonLoose(stdout); return Array.isArray(value) ? (value.filter(isRecord) as Array>) : []; } +/** + * Parse a wrangler `--json` stdout into an array, distinguishing a VALID JSON + * array (returns it, possibly empty) from unparseable/non-array output (returns + * `undefined`). Used where an empty list must mean "authoritatively absent" and + * garbage must NOT be silently coerced to "absent" (the KV pre-check). + */ +function parseJsonArrayStrict(stdout: string): Array> | undefined { + const value = parseJsonLoose(stdout); + return Array.isArray(value) + ? (value.filter(isRecord) as Array>) + : undefined; +} + /** Parse a wrangler `--json` stdout into an object, tolerating noise/failure. */ function parseJsonObject(stdout: string): Record | undefined { const value = parseJsonLoose(stdout); @@ -175,16 +251,31 @@ function findBindingEntry(list: unknown, binding: string): Record { - if (!existsSync(rootWranglerPath)) { +/** + * Read + parse the root wrangler config (fresh each call — wrangler mutates it). + * BOM-aware (wrangler + the Task 3 writer strip a leading BOM before parsing, so + * we must too, else a BOM-prefixed config would look unparseable). FAILS CLOSED + * on a genuine parse error or a non-object root: never silently treat a real + * config as `{}`, which would miss existing ids (re-provision/orphan) and mask a + * corrupt file. A missing file is fine (nothing to read-first → `{}`). + */ +function readRootConfig(configPath: string): Record { + if (!existsSync(configPath)) { return {}; } + const raw = readFileSync(configPath, 'utf-8'); + const content = raw.startsWith(UTF8_BOM) ? raw.slice(UTF8_BOM.length) : raw; const errors: Array = []; - const parsed = parse(readFileSync(rootWranglerPath, 'utf-8'), errors, { - allowTrailingComma: true, - }); - return errors.length === 0 && isRecord(parsed) ? parsed : {}; + const parsed = parse(content, errors, { allowTrailingComma: true }); + if (errors.length > 0) { + throw new Error( + `provision: cannot read "${configPath}" — it is not valid JSON/JSONC. Fix the file and retry.`, + ); + } + if (!isRecord(parsed)) { + throw new Error(`provision: "${configPath}" must be a JSON object.`); + } + return parsed; } // --- wrangler output extractors (one place to align field names with wrangler) --- @@ -203,9 +294,12 @@ function extractD1( return { id, name: typeof entry.name === 'string' ? entry.name : undefined }; } -/** `wrangler kv namespace list` → `[{ id, title }]`. */ -function extractKvMatches(stdout: string, title: string): Array<{ id: string; title: string }> { - return parseJsonArray(stdout) +/** `wrangler kv namespace list` → `[{ id, title }]` (already parsed + validated). */ +function extractKvMatches( + list: Array>, + title: string, +): Array<{ id: string; title: string }> { + return list .filter((ns) => ns.title === title) .map((ns) => ({ id: String(ns.id ?? ''), title })) .filter((ns) => ns.id.length > 0); @@ -228,15 +322,49 @@ function isMissingResourceError(result: RunWranglerResult): boolean { return /does not exist|not found|could not be found|no such/i.test(text); } +/** + * True ONLY when a failing `r2 bucket info` is the confirmed bucket-not-found + * signal (CF API code `10006` / a not-found message). In wrangler 4.107 `r2 + * bucket info` does the bucket lookup AND a separate metrics/GraphQL query, so an + * EXISTING bucket can exit non-zero on a metrics/auth/network failure. Creating + * on any non-zero would then error on the existing name (spurious failure) or, in + * the worst case, risk a duplicate — so we create ONLY on this confirmed signal. + */ +function isBucketNotFoundError(result: RunWranglerResult): boolean { + const text = `${result.stdout}\n${result.stderr}`; + return text.includes('10006') || isMissingResourceError(result); +} + +/** + * Throw when the writer could not attach `migrations_dir` because no matching + * `d1_databases[]` entry exists. The provisioner only writes `migrations_dir` + * AFTER establishing the D1 entry (create/adopt/reuse), so a skip here means the + * D1 resource is unexpectedly absent from the config — never silently succeed + * with the gap unfilled. + */ +function assertD1MigrationsDirApplied( + result: WriteProvisionedWranglerResult, + binding: string, + configPath: string, +): void { + if (result.d1MigrationsDirSkipped) { + throw new Error( + `provision: could not set migrations_dir for D1 binding ${JSON.stringify(binding)} — no ` + + `matching d1_databases entry exists in "${configPath}" after provisioning it. The D1 ` + + `resource was not written to the config as expected; inspect the file and retry.`, + ); + } +} + /** * Ensure every resource in `desired` exists in the user's own Cloudflare account, * creating the missing ones by shelling out to `wrangler` and letting * `wrangler create --update-config` write real ids into the root config. * For resources that already exist (adopt path) — where `create` cannot run — - * the real id is captured from `wrangler list/info` and written through the - * Task 3 writer's adopt fields. Pure provisioning: NO build, deploy, or - * drift-check. Idempotent + read-first: a binding already pointing at a real id - * is left untouched. + * the real id is captured from `wrangler list/info` (or, for Hyperdrive, + * the account-list existence seam) and written through the Task 3 writer's adopt + * fields. Pure provisioning: NO build, deploy, or drift-check. Idempotent + + * read-first: a binding already pointing at a real id is left untouched. */ export async function provisionCloudflareResources( root: string, @@ -245,48 +373,68 @@ export async function provisionCloudflareResources( opts?: { runWrangler?: RunWrangler; accountId?: string; + apiToken?: string; + listHyperdriveConfigs?: ListHyperdriveConfigs; log?: ProvisionLogger; }, ): Promise { await Promise.resolve(); + // ONE exact config path threaded through read-first, wrangler `-c`, AND the + // writer, so reads and writes can never hit different files (no split-brain). + const configPath = rootWranglerPath; const runWrangler = opts?.runWrangler ?? createDefaultRunWrangler(root, { accountId: opts?.accountId }); const log = opts?.log ?? defaultLogger; const resources: Array = []; - let configPath = rootWranglerPath; - const write = (patch: Parameters[1]): void => { - configPath = writeProvisionedWranglerConfig(root, patch).configPath; - }; + const write = ( + patch: Parameters[1], + ): WriteProvisionedWranglerResult => writeProvisionedWranglerConfig(root, patch, { configPath }); // --- D1 ------------------------------------------------------------------ if (desired.d1) { const { binding, name } = desired.d1; - const entry = findBindingEntry(readRootConfig(rootWranglerPath).d1_databases, binding); + const entry = findBindingEntry(readRootConfig(configPath).d1_databases, binding); if (entry && isRealValue(entry.database_id)) { + // Read-first hit. A prior run may have created the D1 (id written) then + // crashed BEFORE migrations_dir was set, so ensure + verify it here too — + // idempotent when already present — so the retry converges rather than + // leaving the gap unfilled forever. + const res = write({ d1MigrationsDir: { binding, dir: D1_MIGRATIONS_DIR } }); + assertD1MigrationsDirApplied(res, binding, configPath); resources.push({ kind: 'd1', binding, name, action: 'reused', id: entry.database_id }); } else { const existing = extractD1( - parseJsonArray(runWrangler('d1 list --json', { captureJson: true }).stdout).find( + parseJsonArray(runWrangler(['d1', 'list', '--json'], { captureJson: true }).stdout).find( (db) => db.name === name, ), ); if (existing) { log.info(`adopted existing D1 database ${name} (${existing.id})`); - write({ + const res = write({ d1: { binding, databaseName: name, databaseId: existing.id }, d1MigrationsDir: { binding, dir: D1_MIGRATIONS_DIR }, }); + assertD1MigrationsDirApplied(res, binding, configPath); resources.push({ kind: 'd1', binding, name, action: 'adopted', id: existing.id }); } else { - const created = runWrangler( - `d1 create ${name} --binding ${binding} --update-config -c ${rootWranglerPath}`, - ); + const created = runWrangler([ + 'd1', + 'create', + name, + '--binding', + binding, + '--update-config', + '-c', + configPath, + ]); if (created.status !== 0) { if (isAlreadyExistsError(created)) { // TOCTOU: created between our list and create (code 7502). Capture // the id via `d1 info` and adopt instead of failing. const info = extractD1( - parseJsonObject(runWrangler(`d1 info ${name} --json`, { captureJson: true }).stdout), + parseJsonObject( + runWrangler(['d1', 'info', name, '--json'], { captureJson: true }).stdout, + ), ); if (!info) { throw new Error( @@ -295,10 +443,11 @@ export async function provisionCloudflareResources( ); } log.info(`adopted existing D1 database ${name} (${info.id})`); - write({ + const res = write({ d1: { binding, databaseName: name, databaseId: info.id }, d1MigrationsDir: { binding, dir: D1_MIGRATIONS_DIR }, }); + assertD1MigrationsDirApplied(res, binding, configPath); resources.push({ kind: 'd1', binding, name, action: 'adopted', id: info.id }); } else { throw new Error( @@ -308,7 +457,8 @@ export async function provisionCloudflareResources( } else { // wrangler wrote database_id/database_name via --update-config; fill // the migrations_dir it does not. - write({ d1MigrationsDir: { binding, dir: D1_MIGRATIONS_DIR } }); + const res = write({ d1MigrationsDir: { binding, dir: D1_MIGRATIONS_DIR } }); + assertD1MigrationsDirApplied(res, binding, configPath); resources.push({ kind: 'd1', binding, name, action: 'created' }); } } @@ -317,15 +467,32 @@ export async function provisionCloudflareResources( // --- KV ------------------------------------------------------------------ for (const { binding, title } of desired.kvNamespaces) { - const entry = findBindingEntry(readRootConfig(rootWranglerPath).kv_namespaces, binding); + const entry = findBindingEntry(readRootConfig(configPath).kv_namespaces, binding); if (entry && isRealValue(entry.id)) { resources.push({ kind: 'kv', binding, name: title, action: 'reused', id: entry.id }); continue; } - const matches = extractKvMatches( - runWrangler('kv namespace list', { captureJson: true }).stdout, - title, - ); + // KV titles are NOT account-unique and `kv namespace create` never dedupes, + // so this list is the ONLY orphan guard. Require status 0 AND a schema-valid + // JSON array before treating an absent title as authoritative — otherwise a + // nonzero/auth/network/truncated result would parse as "absent" and mint a + // duplicate namespace. Fail closed (throw) instead. + const listResult = runWrangler(['kv', 'namespace', 'list'], { captureJson: true }); + if (listResult.status !== 0) { + throw new Error( + `provision: "wrangler kv namespace list" failed: ${listResult.stderr || listResult.stdout}. ` + + `Cannot verify whether KV namespace ${JSON.stringify(title)} exists; refusing to create a ` + + `possible duplicate (KV titles are not account-unique).`, + ); + } + const parsedList = parseJsonArrayStrict(listResult.stdout); + if (!parsedList) { + throw new Error( + `provision: "wrangler kv namespace list" did not return a JSON array; cannot verify whether ` + + `KV namespace ${JSON.stringify(title)} exists. Refusing to create a possible duplicate.`, + ); + } + const matches = extractKvMatches(parsedList, title); if (matches.length > 0) { // KV titles are NOT account-unique. Tie-break deterministically on the // lexicographically-smallest id so re-runs adopt the same namespace. @@ -341,9 +508,17 @@ export async function provisionCloudflareResources( write({ kv: { binding, id: chosen.id } }); resources.push({ kind: 'kv', binding, name: title, action: 'adopted', id: chosen.id }); } else { - const created = runWrangler( - `kv namespace create ${title} --binding ${binding} --update-config -c ${rootWranglerPath}`, - ); + const created = runWrangler([ + 'kv', + 'namespace', + 'create', + title, + '--binding', + binding, + '--update-config', + '-c', + configPath, + ]); if (created.status !== 0) { throw new Error( `provision: "wrangler kv namespace create ${title}" failed: ${created.stderr || created.stdout}`, @@ -356,65 +531,121 @@ export async function provisionCloudflareResources( // --- R2 ------------------------------------------------------------------ if (desired.r2) { const { binding, bucketName } = desired.r2; - const entry = findBindingEntry(readRootConfig(rootWranglerPath).r2_buckets, binding); + const entry = findBindingEntry(readRootConfig(configPath).r2_buckets, binding); if (entry && isRealValue(entry.bucket_name)) { resources.push({ kind: 'r2', binding, name: bucketName, action: 'reused' }); - } else if ( - runWrangler(`r2 bucket info ${bucketName} --json`, { captureJson: true }).status === 0 - ) { - // `wrangler r2 bucket list` is a human table, not JSON. Bucket names ARE - // account-unique, so probe the single bucket by name: `r2 bucket info` - // exits 0 only when the bucket exists (it throws a CF API error otherwise). - // Adopt it — write the `{binding,bucket_name}` entry via the writer. - log.info(`adopted existing R2 bucket ${bucketName}`); - write({ r2: { binding, bucketName } }); - resources.push({ kind: 'r2', binding, name: bucketName, action: 'adopted' }); } else { - // Non-zero info → the bucket does not exist (or is unreachable); create it. - // `r2 bucket create` on an already-existing name errors instead of silently - // duplicating, so a transient info failure can never mint a second bucket — - // the create surfaces the real problem. `--update-config` upserts by binding. - const created = runWrangler( - `r2 bucket create ${bucketName} --binding ${binding} --update-config -c ${rootWranglerPath}`, - ); - if (created.status !== 0) { + // `wrangler r2 bucket list` is a human table, not JSON. Bucket names ARE + // account-unique, so probe the single bucket by name. + const info = runWrangler(['r2', 'bucket', 'info', bucketName, '--json'], { + captureJson: true, + }); + if (info.status === 0) { + // Adopt it — write the `{binding,bucket_name}` entry via the writer. + log.info(`adopted existing R2 bucket ${bucketName}`); + write({ r2: { binding, bucketName } }); + resources.push({ kind: 'r2', binding, name: bucketName, action: 'adopted' }); + } else if (isBucketNotFoundError(info)) { + // CONFIRMED not-found → create. `--update-config` upserts by binding. + const created = runWrangler([ + 'r2', + 'bucket', + 'create', + bucketName, + '--binding', + binding, + '--update-config', + '-c', + configPath, + ]); + if (created.status !== 0) { + throw new Error( + `provision: "wrangler r2 bucket create ${bucketName}" failed: ${created.stderr || created.stdout}`, + ); + } + resources.push({ kind: 'r2', binding, name: bucketName, action: 'created' }); + } else { + // A non-zero that is NOT the confirmed not-found signal (auth/metrics/ + // network) — the bucket may well EXIST. Surface the real error instead of + // attempting a create that would spuriously fail or risk a duplicate. throw new Error( - `provision: "wrangler r2 bucket create ${bucketName}" failed: ${created.stderr || created.stdout}`, + `provision: "wrangler r2 bucket info ${bucketName}" failed: ${info.stderr || info.stdout}. ` + + `Cannot determine whether the bucket exists; refusing to create a possible duplicate.`, ); } - resources.push({ kind: 'r2', binding, name: bucketName, action: 'created' }); } } // --- Hyperdrive ---------------------------------------------------------- // wrangler 4.107 has NO machine-readable Hyperdrive list (`hyperdrive list` - // prints a cli-table3 box table; there is no `--json` and no get-by-NAME — - // `hyperdrive get` takes an id). So account-list adoption is not reliably - // possible; idempotency comes from read-first on the config (where the id - // `wrangler hyperdrive create --update-config` wrote already lives) plus - // create otherwise. The writer's `hyperdrive` adopt field stays available for - // Task 6, which can supply a known id from its own preflight. + // prints a cli-table3 box table; there is no `--json`, and `hyperdrive get` + // takes an id, not a name). `hyperdrive create` on a NON-UNIQUE name silently + // mints a DUPLICATE config (new UUID), so a re-run without the id in config + // would keep minting duplicates. Existence is therefore checked via an + // INJECTABLE Cloudflare REST seam (GET …/hyperdrive/configs, matched by name): + // reliable + fully testable (unit tests stub it, no real network). Adoption is + // attempted BEFORE the connection-string gate (it needs no connection string). if (desired.hyperdrive) { const { binding, name, connectionString } = desired.hyperdrive; - const entry = findBindingEntry(readRootConfig(rootWranglerPath).hyperdrive, binding); + const entry = findBindingEntry(readRootConfig(configPath).hyperdrive, binding); if (entry && isRealValue(entry.id)) { resources.push({ kind: 'hyperdrive', binding, name, action: 'reused', id: entry.id }); - } else if (!connectionString) { - // CI-safe: never prompt. Task 6/preflight is responsible for obtaining a - // connection string; without one we cannot create the config. - const reason = 'no connection string provided'; - log.warn(`skipping Hyperdrive config ${name}: ${reason}`); - resources.push({ kind: 'hyperdrive', binding, name, action: 'skipped', reason }); } else { - const created = runWrangler( - `hyperdrive create ${name} --connection-string=${connectionString} --binding ${binding} --update-config -c ${rootWranglerPath}`, - ); - if (created.status !== 0) { + const lister = + opts?.listHyperdriveConfigs ?? + createDefaultListHyperdriveConfigs(opts?.accountId, opts?.apiToken); + let existing: HyperdriveConfig | undefined; + let existenceUnknown = true; + if (lister) { + try { + const configs = await lister(); + existing = configs.find((c) => c.name === name); + existenceUnknown = false; + } catch (error) { + log.warn(`Hyperdrive existence check for ${name} failed: ${(error as Error).message}`); + existenceUnknown = true; + } + } + if (existing) { + log.info(`adopted existing Hyperdrive config ${name} (${existing.id})`); + write({ hyperdrive: { binding, id: existing.id } }); + resources.push({ kind: 'hyperdrive', binding, name, action: 'adopted', id: existing.id }); + } else if (!connectionString) { + // No connection string → we can never CREATE, so there is no duplicate + // risk whether or not existence is known: skip (structured, never prompts). + const reason = 'no connection string provided'; + log.warn(`skipping Hyperdrive config ${name}: ${reason}`); + resources.push({ kind: 'hyperdrive', binding, name, action: 'skipped', reason }); + } else if (existenceUnknown) { + // A connection string is present (we WOULD create) but existence is + // unverifiable → FAIL CLOSED. Blindly creating duplicates a same-named + // config. throw new Error( - `provision: "wrangler hyperdrive create ${name}" failed: ${created.stderr || created.stdout}`, + `provision: cannot verify whether Hyperdrive config ${JSON.stringify(name)} already ` + + `exists (no existence check available — provide an accountId + API token, or inject a ` + + `listHyperdriveConfigs seam). "wrangler hyperdrive create" silently duplicates a ` + + `same-named config, so provisioning refuses to create it blindly.`, ); + } else { + // Confirmed absent + connection string present → create. + const created = runWrangler([ + 'hyperdrive', + 'create', + name, + `--connection-string=${connectionString}`, + '--binding', + binding, + '--update-config', + '-c', + configPath, + ]); + if (created.status !== 0) { + throw new Error( + `provision: "wrangler hyperdrive create ${name}" failed: ${created.stderr || created.stdout}`, + ); + } + resources.push({ kind: 'hyperdrive', binding, name, action: 'created' }); } - resources.push({ kind: 'hyperdrive', binding, name, action: 'created' }); } } @@ -425,7 +656,7 @@ export async function provisionCloudflareResources( // queue RESOURCE exists — binding the producers/consumers into the config is // app-class-aware and belongs to Task 6. for (const name of desired.queues) { - const info = runWrangler(`queues info ${name}`); + const info = runWrangler(['queues', 'info', name]); if (info.status === 0) { resources.push({ kind: 'queue', name, action: 'reused' }); continue; @@ -435,7 +666,7 @@ export async function provisionCloudflareResources( `provision: "wrangler queues info ${name}" failed: ${info.stderr || info.stdout}`, ); } - const created = runWrangler(`queues create ${name}`); + const created = runWrangler(['queues', 'create', name]); if (created.status !== 0) { throw new Error( `provision: "wrangler queues create ${name}" failed: ${created.stderr || created.stdout}`, diff --git a/packages/void/src/cli/wrangler.ts b/packages/void/src/cli/wrangler.ts index ac1257378..581b4101c 100644 --- a/packages/void/src/cli/wrangler.ts +++ b/packages/void/src/cli/wrangler.ts @@ -817,19 +817,24 @@ export type WriteProvisionedWranglerResult = { export function writeProvisionedWranglerConfig( root: string, patch: ProvisionedWranglerPatch, + options?: { configPath?: string }, ): WriteProvisionedWranglerResult { - // Resolve the root config file (same discovery idiom as the rest of this - // file: prefer wrangler.jsonc, fall back to wrangler.json, else create - // wrangler.jsonc). - let configPath: string | undefined; - for (const name of ['wrangler.jsonc', 'wrangler.json']) { - const p = join(root, name); - if (existsSync(p)) { - configPath = p; - break; + // Resolve the root config file. When the caller pins an EXACT `configPath` + // (the provisioner does, so its read-first parse, wrangler `-c`, and this + // write all operate on the SAME file — no split-brain), honor it verbatim. + // Otherwise fall back to the same discovery idiom as the rest of this file: + // prefer wrangler.jsonc, fall back to wrangler.json, else create wrangler.jsonc. + let configPath = options?.configPath; + if (configPath === undefined) { + for (const name of ['wrangler.jsonc', 'wrangler.json']) { + const p = join(root, name); + if (existsSync(p)) { + configPath = p; + break; + } } + configPath ??= join(root, 'wrangler.jsonc'); } - configPath ??= join(root, 'wrangler.jsonc'); // Node's utf-8 read preserves a leading BOM; wrangler itself strips the BOM // before parsing. Match that: strip it for parsing/editing, then re-add it on @@ -893,12 +898,26 @@ export function writeProvisionedWranglerConfig( set(['compatibility_date'], patch.compatibilityDate); } + // Lazily-built map of EVERY binding name already used anywhere in the config + // (keyed name → wrangler's friendly owner label). Shared by the adopt-append + // guard below and the queue-producer append guard further down; it is keyed on + // the initial `parsed` (constant within this call), so memoizing once is safe. + let workerBindingOwners: Map | undefined; + // Adopt fields — write the REAL id/name of an already-existing resource into // the binding's config entry (create the array + entry if absent), keyed by // `binding`. Runs BEFORE `migrations_dir` so an adopt that CREATES a D1 entry // is visible to the `migrations_dir` upsert below (which re-parses `text`). // Each is an idempotent property upsert: a matched entry has only the supplied // props set (siblings + comments preserved), a missing one is appended. + // + // The upsert upholds the SAME worker-wide binding invariants the producer path + // enforces (see `validateBindingsHaveUniqueNames` mirror below): + // - >1 entry in the target array sharing `binding` → fail closed (ambiguous: + // adoption cannot pick which to write, and wrangler rejects the duplicate). + // - APPENDING a new entry whose binding is already used by ANOTHER binding + // type (a KV bound `DB`, a var, a secret, …) → fail closed (a cross-type + // duplicate binding name wrangler rejects at deploy). const upsertAdoptedBinding = ( field: string, binding: string, @@ -906,8 +925,22 @@ export function writeProvisionedWranglerConfig( ): void => { const rawList = parseJsonc(text)[field]; const currentList = Array.isArray(rawList) ? [...rawList] : []; - const index = currentList.findIndex((entry) => isRecord(entry) && entry.binding === binding); - if (index >= 0) { + const matches: Array = []; + for (let i = 0; i < currentList.length; i++) { + const entry = currentList[i]; + if (isRecord(entry) && entry.binding === binding) { + matches.push(i); + } + } + if (matches.length > 1) { + throw new Error( + `deploy: cannot update "${configPath}" — binding ${JSON.stringify(binding)} is declared ` + + `${matches.length} times in "${field}". Adoption cannot choose which entry to write the ` + + `real id into, and wrangler rejects duplicate binding names. Remove the duplicate and retry.`, + ); + } + if (matches.length === 1) { + const index = matches[0]; const existing = isRecord(currentList[index]) ? currentList[index] : {}; for (const [key, value] of Object.entries(properties)) { if (!jsonEqual(existing[key], value)) { @@ -915,6 +948,22 @@ export function writeProvisionedWranglerConfig( } } } else { + // APPEND branch only (no entry in this array owns the binding). wrangler + // requires every binding NAME to be unique across the ENTIRE worker, so + // before appending, fail closed if the binding is already used by any OTHER + // binding type. Reuse the same worker-wide collector the producer guard + // uses (do NOT fork it) — a cross-type collision here yields a config + // wrangler rejects at deploy. + workerBindingOwners ??= collectWorkerBindingNames(parsed); + const owner = workerBindingOwners.get(binding); + if (owner !== undefined) { + throw new Error( + `deploy: cannot update "${configPath}" — cannot adopt ${JSON.stringify(binding)} into ` + + `"${field}" because that binding name is already used by a ${owner} binding. Wrangler ` + + `requires every binding name to be unique across the whole worker, so this would make ` + + `the config undeployable. Rename the conflicting binding and retry.`, + ); + } text = applyMemberEdit( text, [field, currentList.length], @@ -984,13 +1033,6 @@ export function writeProvisionedWranglerConfig( } } - // Lazily-built map of EVERY binding name already used anywhere in the config - // (keyed name → wrangler's friendly owner label), EXCLUDING `queues` (producer - // identity is handled by the binding-match logic below; consumers carry no - // binding). Used to fail closed before APPENDING a new producer whose binding - // collides with a worker-wide binding name (see the producers branch). - let workerBindingOwners: Map | undefined; - // queues — idempotent upsert into `queues.producers[]` / `queues.consumers[]`. // Each section keys on its wrangler identity: // - PRODUCERS key on `binding`. wrangler requires producer binding names to diff --git a/packages/void/test/unit/provision.test.ts b/packages/void/test/unit/provision.test.ts index 133e2fb59..d3ae2d363 100644 --- a/packages/void/test/unit/provision.test.ts +++ b/packages/void/test/unit/provision.test.ts @@ -31,11 +31,16 @@ const silentLog = { info: () => {}, warn: () => {} }; type Handler = { match: RegExp; reply: (sub: string) => RunWranglerResult }; -/** Build an injectable runWrangler stub that records every subcommand. */ -function makeRunner(handlers: Array): { run: RunWrangler; calls: Array } { - const calls: Array = []; - const run: RunWrangler = (sub) => { - calls.push(sub); +/** + * Build an injectable runWrangler stub that records every argv ARRAY. Handlers + * match on the space-joined argv (regex convenience), but `calls` holds the exact + * argv arrays so tests assert that untrusted values stay single, literal tokens. + */ +function makeRunner(handlers: Array): { run: RunWrangler; calls: Array> } { + const calls: Array> = []; + const run: RunWrangler = (args) => { + calls.push(args); + const sub = args.join(' '); for (const handler of handlers) { if (handler.match.test(sub)) { return handler.reply(sub); @@ -46,12 +51,21 @@ function makeRunner(handlers: Array): { run: RunWrangler; calls: Array< return { run, calls }; } +/** True when an argv array is a `wrangler …` invocation of a given kind. */ +function isCall(argv: Array, ...prefix: Array): boolean { + return prefix.every((token, i) => argv[i] === token); +} + const ok = (stdout = ''): RunWranglerResult => ({ stdout, stderr: '', status: 0 }); const fail = (stdout: string, stderr = ''): RunWranglerResult => ({ stdout, stderr, status: 1 }); /** Mimic `wrangler create --update-config`: upsert a binding entry by binding. */ -function simulateUpdateConfig(field: string, entry: Record): void { - const cfg = readConfig(); +function simulateUpdateConfig( + field: string, + entry: Record, + path = wranglerPath, +): void { + const cfg = parseJsoncRaw(readFileSync(path, 'utf-8')) as Record; const list = Array.isArray(cfg[field]) ? (cfg[field] as Array>) : []; const index = list.findIndex((e) => e.binding === entry.binding); if (index >= 0) { @@ -60,13 +74,13 @@ function simulateUpdateConfig(field: string, entry: Record): vo list.push(entry); } cfg[field] = list; - writeFileSync(wranglerPath, `${JSON.stringify(cfg, null, 2)}\n`); + writeFileSync(path, `${JSON.stringify(cfg, null, 2)}\n`); } const emptyDesired: DesiredResourceSet = { kvNamespaces: [], queues: [] }; describe('provisionCloudflareResources — read-first', () => { - it('skips D1 entirely when the binding already has a real database_id (records reused, no wrangler call)', async () => { + it('skips D1 create when the binding already has a real database_id (records reused, no wrangler call)', async () => { setup( JSON.stringify({ d1_databases: [{ binding: 'DB', database_name: 'myapp-db', database_id: 'real-uuid-1' }], @@ -79,6 +93,7 @@ describe('provisionCloudflareResources — read-first', () => { { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, { runWrangler: run, log: silentLog }, ); + // Read-first: no wrangler invocation at all (migrations_dir is filled via the writer). expect(calls).toEqual([]); expect(result.resources).toEqual([ { kind: 'd1', binding: 'DB', name: 'myapp-db', action: 'reused', id: 'real-uuid-1' }, @@ -111,11 +126,166 @@ describe('provisionCloudflareResources — read-first', () => { { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, { runWrangler: run, log: silentLog }, ); - expect(calls[0]).toBe('d1 list --json'); + expect(calls[0]).toEqual(['d1', 'list', '--json']); expect(result.resources[0].action).toBe('created'); }); }); +describe('provisionCloudflareResources — argv injection safety', () => { + it('passes a Hyperdrive connection string with shell metacharacters as ONE literal argv element', async () => { + setup(JSON.stringify({ name: 'app' })); + // & $ ; and a space would all split/inject under a shell — must stay one token. + const conn = 'postgres://u:p@h/db?sslmode=require&opt=$X;DROP TABLE t'; + const { run, calls } = makeRunner([ + { + match: /^hyperdrive create /, + reply: () => { + simulateUpdateConfig('hyperdrive', { binding: 'HYPERDRIVE', id: 'new-hd-id' }); + return ok(); + }, + }, + ]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + hyperdrive: { binding: 'HYPERDRIVE', name: 'myapp-hd', connectionString: conn }, + }, + { runWrangler: run, log: silentLog, listHyperdriveConfigs: async () => [] }, + ); + const createCall = calls.find((c) => isCall(c, 'hyperdrive', 'create')); + expect(createCall).toEqual([ + 'hyperdrive', + 'create', + 'myapp-hd', + `--connection-string=${conn}`, + '--binding', + 'HYPERDRIVE', + '--update-config', + '-c', + wranglerPath, + ]); + // The whole connection string (with & $ ; and a space) is a SINGLE element. + expect(createCall!.filter((t) => t.includes('DROP TABLE t'))).toHaveLength(1); + expect(result.resources[0].action).toBe('created'); + }); + + it('passes a config path containing spaces as ONE literal argv element (no shell splitting)', async () => { + const spacedDir = join(import.meta.dirname, '.tmp provision space'); + const spacedPath = join(spacedDir, 'wrangler.jsonc'); + rmSync(spacedDir, { recursive: true, force: true }); + mkdirSync(spacedDir, { recursive: true }); + writeFileSync(spacedPath, JSON.stringify({ name: 'app' })); + try { + const { run, calls } = makeRunner([ + { match: /^d1 list --json$/, reply: () => ok('[]') }, + { + match: /^d1 create /, + reply: () => { + simulateUpdateConfig( + 'd1_databases', + { binding: 'DB', database_name: 'myapp-db', database_id: 'created-uuid' }, + spacedPath, + ); + return ok(); + }, + }, + ]); + const result = await provisionCloudflareResources( + spacedDir, + spacedPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, + { runWrangler: run, log: silentLog }, + ); + const createCall = calls.find((c) => isCall(c, 'd1', 'create')); + expect(spacedPath).toContain(' '); + // The spaced path is the FINAL single element (a shell would have split it). + expect(createCall).toEqual([ + 'd1', + 'create', + 'myapp-db', + '--binding', + 'DB', + '--update-config', + '-c', + spacedPath, + ]); + expect(result.resources[0].action).toBe('created'); + } finally { + rmSync(spacedDir, { recursive: true, force: true }); + } + }); +}); + +describe('provisionCloudflareResources — config discovery (fail closed, single file)', () => { + it('reads a BOM-prefixed config correctly — ids found, no re-provision', async () => { + setup( + '' + + JSON.stringify({ + d1_databases: [{ binding: 'DB', database_name: 'myapp-db', database_id: 'real-uuid-1' }], + }), + ); + const { run, calls } = makeRunner([]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, + { runWrangler: run, log: silentLog }, + ); + // A BOM must NOT defeat read-first (a fail-open-to-{} parse would re-provision). + expect(calls.some((c) => c[0] === 'd1')).toBe(false); + expect(result.resources[0]).toMatchObject({ kind: 'd1', action: 'reused', id: 'real-uuid-1' }); + // BOM survives the writer's migrations_dir fill. + expect(readFileSync(wranglerPath, 'utf-8').charCodeAt(0)).toBe(0xfeff); + }); + + it('fails closed on a malformed root config — throws, no wrangler calls, no write', async () => { + const malformed = '{ "name": "x" "d1_databases": [] }'; // missing comma + setup(malformed); + const { run, calls } = makeRunner([{ match: /.*/, reply: () => ok() }]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, + { runWrangler: run, log: silentLog }, + ), + ).rejects.toThrow(/not valid JSON/); + expect(calls).toEqual([]); + expect(readFileSync(wranglerPath, 'utf-8')).toBe(malformed); + }); + + it('reads and writes the SAME single resolved file when both wrangler.json and wrangler.jsonc exist', async () => { + rmSync(tmpDir, { recursive: true, force: true }); + mkdirSync(tmpDir, { recursive: true }); + const jsoncPath = join(tmpDir, 'wrangler.jsonc'); + const jsonPath = join(tmpDir, 'wrangler.json'); + // The caller pins wrangler.json; the OLD writer rediscovered .jsonc → split-brain. + writeFileSync(jsonPath, JSON.stringify({ kv_namespaces: [{ binding: 'KV', id: 'local' }] })); + writeFileSync(jsoncPath, JSON.stringify({ kv_namespaces: [{ binding: 'KV', id: 'local' }] })); + const { run } = makeRunner([ + { + match: /^kv namespace list$/, + reply: () => ok(JSON.stringify([{ id: 'real-kv-id', title: 'myapp-kv' }])), + }, + ]); + const result = await provisionCloudflareResources( + tmpDir, + jsonPath, + { ...emptyDesired, kvNamespaces: [{ binding: 'KV', title: 'myapp-kv' }] }, + { runWrangler: run, log: silentLog }, + ); + expect(result.configPath).toBe(jsonPath); + // The adopted id landed in the passed file (wrangler.json) — NOT split into .jsonc. + const jsonAfter = parseJsoncRaw(readFileSync(jsonPath, 'utf-8')) as Record; + expect((jsonAfter.kv_namespaces as Array>)[0].id).toBe('real-kv-id'); + // wrangler.jsonc is untouched (still the placeholder). + const jsoncAfter = parseJsoncRaw(readFileSync(jsoncPath, 'utf-8')) as Record; + expect((jsoncAfter.kv_namespaces as Array>)[0].id).toBe('local'); + }); +}); + describe('provisionCloudflareResources — D1', () => { it('creates a missing D1 with --update-config then writes migrations_dir', async () => { setup(JSON.stringify({ name: 'app' })); @@ -140,8 +310,8 @@ describe('provisionCloudflareResources — D1', () => { { runWrangler: run, log: silentLog }, ); expect(calls).toEqual([ - 'd1 list --json', - `d1 create myapp-db --binding DB --update-config -c ${wranglerPath}`, + ['d1', 'list', '--json'], + ['d1', 'create', 'myapp-db', '--binding', 'DB', '--update-config', '-c', wranglerPath], ]); const d1 = readConfig().d1_databases as Array>; expect(d1).toEqual([ @@ -157,6 +327,36 @@ describe('provisionCloudflareResources — D1', () => { ]); }); + it('repairs a missing migrations_dir on the read-first reused path (retry convergence, no duplicate D1)', async () => { + // Simulates a prior run that created the D1 (real id written) then crashed + // before migrations_dir. The retry takes read-first → reused and must still + // fill migrations_dir — without re-creating (no duplicate D1). + setup( + JSON.stringify({ + d1_databases: [{ binding: 'DB', database_name: 'myapp-db', database_id: 'real-uuid-1' }], + }), + ); + const { run, calls } = makeRunner([]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, + { runWrangler: run, log: silentLog }, + ); + expect(calls).toEqual([]); // read-first — no create, no list + const d1 = readConfig().d1_databases as Array>; + expect(d1).toHaveLength(1); // no duplicate D1 minted + expect(d1[0]).toEqual({ + binding: 'DB', + database_name: 'myapp-db', + database_id: 'real-uuid-1', + migrations_dir: './db/migrations', + }); + expect(result.resources).toEqual([ + { kind: 'd1', binding: 'DB', name: 'myapp-db', action: 'reused', id: 'real-uuid-1' }, + ]); + }); + it('adopts an existing D1 found in the account (list hit) — no create, writes id + migrations_dir', async () => { setup( JSON.stringify({ @@ -175,8 +375,8 @@ describe('provisionCloudflareResources — D1', () => { { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, { runWrangler: run, log: silentLog }, ); - expect(calls).toEqual(['d1 list --json']); - expect(calls.some((c) => c.startsWith('d1 create'))).toBe(false); + expect(calls).toEqual([['d1', 'list', '--json']]); + expect(calls.some((c) => isCall(c, 'd1', 'create'))).toBe(false); const d1 = readConfig().d1_databases as Array>; expect(d1).toEqual([ { @@ -215,9 +415,9 @@ describe('provisionCloudflareResources — D1', () => { { runWrangler: run, log: silentLog }, ); expect(calls).toEqual([ - 'd1 list --json', - `d1 create myapp-db --binding DB --update-config -c ${wranglerPath}`, - 'd1 info myapp-db --json', + ['d1', 'list', '--json'], + ['d1', 'create', 'myapp-db', '--binding', 'DB', '--update-config', '-c', wranglerPath], + ['d1', 'info', 'myapp-db', '--json'], ]); const d1 = readConfig().d1_databases as Array>; expect(d1[0]).toMatchObject({ binding: 'DB', database_id: 'raced-uuid' }); @@ -265,7 +465,7 @@ describe('provisionCloudflareResources — KV', () => { { ...emptyDesired, kvNamespaces: [{ binding: 'KV', title: 'myapp-kv' }] }, { runWrangler: run, log: silentLog }, ); - expect(calls).toEqual(['kv namespace list']); + expect(calls).toEqual([['kv', 'namespace', 'list']]); const kv = readConfig().kv_namespaces as Array>; expect(kv).toEqual([{ binding: 'KV', id: 'real-kv-id' }]); expect(result.resources).toEqual([ @@ -292,14 +492,75 @@ describe('provisionCloudflareResources — KV', () => { { runWrangler: run, log: silentLog }, ); expect(calls).toEqual([ - 'kv namespace list', - `kv namespace create myapp-isr --binding ISR_CACHE --update-config -c ${wranglerPath}`, + ['kv', 'namespace', 'list'], + [ + 'kv', + 'namespace', + 'create', + 'myapp-isr', + '--binding', + 'ISR_CACHE', + '--update-config', + '-c', + wranglerPath, + ], ]); expect(result.resources).toEqual([ { kind: 'kv', binding: 'ISR_CACHE', name: 'myapp-isr', action: 'created' }, ]); }); + it('fails closed (no create) when `kv namespace list` exits non-zero', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { + match: /^kv namespace list$/, + reply: () => fail('', 'X [ERROR] Authentication error [code: 10000]'), + }, + ]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, kvNamespaces: [{ binding: 'KV', title: 'myapp-kv' }] }, + { runWrangler: run, log: silentLog }, + ), + ).rejects.toThrow(/kv namespace list.*failed/s); + expect(calls.some((c) => isCall(c, 'kv', 'namespace', 'create'))).toBe(false); + }); + + it('fails closed (no create) when `kv namespace list` returns non-JSON output', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { match: /^kv namespace list$/, reply: () => ok('not a json list at all') }, + ]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, kvNamespaces: [{ binding: 'KV', title: 'myapp-kv' }] }, + { runWrangler: run, log: silentLog }, + ), + ).rejects.toThrow(/did not return a JSON array/); + expect(calls.some((c) => isCall(c, 'kv', 'namespace', 'create'))).toBe(false); + }); + + it('fails closed (no create) when `kv namespace list` output is truncated', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { match: /^kv namespace list$/, reply: () => ok('[{ "id": "a", "title": "myapp-kv"') }, + ]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, kvNamespaces: [{ binding: 'KV', title: 'myapp-kv' }] }, + { runWrangler: run, log: silentLog }, + ), + ).rejects.toThrow(/did not return a JSON array/); + expect(calls.some((c) => isCall(c, 'kv', 'namespace', 'create'))).toBe(false); + }); + it('picks the lexicographically-smallest id and warns on multiple title matches', async () => { setup(JSON.stringify({ name: 'app' })); const warnings: Array = []; @@ -357,7 +618,7 @@ describe('provisionCloudflareResources — KV', () => { expect(result.resources.map((r) => r.action)).toEqual(['created', 'created']); const kv = readConfig().kv_namespaces as Array>; expect(kv.map((e) => e.binding).sort()).toEqual(['ISR_CACHE', 'KV']); - expect(calls.filter((c) => c.startsWith('kv namespace create'))).toHaveLength(2); + expect(calls.filter((c) => isCall(c, 'kv', 'namespace', 'create'))).toHaveLength(2); }); }); @@ -448,7 +709,7 @@ describe('provisionCloudflareResources — R2', () => { { ...emptyDesired, r2: { binding: 'STORAGE', bucketName: 'myapp-uploads' } }, { runWrangler: run, log: silentLog }, ); - expect(calls).toEqual(['r2 bucket info myapp-uploads --json']); + expect(calls).toEqual([['r2', 'bucket', 'info', 'myapp-uploads', '--json']]); expect(readConfig().r2_buckets).toEqual([{ binding: 'STORAGE', bucket_name: 'myapp-uploads' }]); expect(result.resources).toEqual([ { kind: 'r2', binding: 'STORAGE', name: 'myapp-uploads', action: 'adopted' }, @@ -480,26 +741,58 @@ describe('provisionCloudflareResources — R2', () => { { runWrangler: run, log: silentLog }, ); expect(calls).toEqual([ - 'r2 bucket info myapp-uploads --json', - `r2 bucket create myapp-uploads --binding STORAGE --update-config -c ${wranglerPath}`, + ['r2', 'bucket', 'info', 'myapp-uploads', '--json'], + [ + 'r2', + 'bucket', + 'create', + 'myapp-uploads', + '--binding', + 'STORAGE', + '--update-config', + '-c', + wranglerPath, + ], ]); expect(result.resources).toEqual([ { kind: 'r2', binding: 'STORAGE', name: 'myapp-uploads', action: 'created' }, ]); }); - it('surfaces a create failure (e.g. auth error) when the bucket cannot be probed or created', async () => { - // Non-zero info → attempt create; `r2 bucket create` on an existing name - // errors rather than duplicating, so a failed create always surfaces the real - // problem instead of minting a second bucket. + it('creates the bucket on a confirmed not-found signal (CF code 10006)', async () => { setup(JSON.stringify({ name: 'app' })); const { run, calls } = makeRunner([ { match: /^r2 bucket info /, - reply: () => fail('', 'X [ERROR] Authentication error [code: 10000]'), + reply: () => + fail('', 'X [ERROR] The bucket you tried to access is not present [code: 10006]'), }, { match: /^r2 bucket create /, + reply: () => { + simulateUpdateConfig('r2_buckets', { binding: 'STORAGE', bucket_name: 'myapp-uploads' }); + return ok(); + }, + }, + ]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, r2: { binding: 'STORAGE', bucketName: 'myapp-uploads' } }, + { runWrangler: run, log: silentLog }, + ); + expect(calls.some((c) => isCall(c, 'r2', 'bucket', 'create'))).toBe(true); + expect(result.resources[0].action).toBe('created'); + }); + + it('does NOT create on an R2 info failure that is not a confirmed not-found (auth/metrics) — surfaces the error', async () => { + // wrangler 4.107 `r2 bucket info` also runs a metrics/GraphQL query, so an + // EXISTING bucket can exit non-zero on an auth/metrics failure. The prior + // "auth error → create" behavior is REVERSED: never create on such a signal. + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { + match: /^r2 bucket info /, reply: () => fail('', 'X [ERROR] Authentication error [code: 10000]'), }, ]); @@ -510,11 +803,9 @@ describe('provisionCloudflareResources — R2', () => { { ...emptyDesired, r2: { binding: 'STORAGE', bucketName: 'myapp-uploads' } }, { runWrangler: run, log: silentLog }, ), - ).rejects.toThrow(/r2 bucket create myapp-uploads.*failed/s); - expect(calls).toEqual([ - 'r2 bucket info myapp-uploads --json', - `r2 bucket create myapp-uploads --binding STORAGE --update-config -c ${wranglerPath}`, - ]); + ).rejects.toThrow(/r2 bucket info myapp-uploads.*(failed|refusing)/s); + // No create attempted (the existing bucket must not be duplicated/errored on). + expect(calls).toEqual([['r2', 'bucket', 'info', 'myapp-uploads', '--json']]); }); }); @@ -543,33 +834,41 @@ describe('provisionCloudflareResources — Hyperdrive', () => { ]); }); - it('skips with a structured reason when no connection string is provided (never prompts)', async () => { - setup(JSON.stringify({ name: 'app' })); - const warnings: Array = []; + it('adopts an existing Hyperdrive config found by name — writes id via the writer, no create', async () => { + setup(JSON.stringify({ hyperdrive: [{ binding: 'HYPERDRIVE', id: 'local' }] })); const { run, calls } = makeRunner([]); const result = await provisionCloudflareResources( tmpDir, wranglerPath, { ...emptyDesired, - hyperdrive: { binding: 'HYPERDRIVE', name: 'myapp-hd', connectionString: '' }, + hyperdrive: { binding: 'HYPERDRIVE', name: 'myapp-hd', connectionString: 'postgres://x' }, + }, + { + runWrangler: run, + log: silentLog, + listHyperdriveConfigs: async () => [ + { id: 'other-hd', name: 'someone-else' }, + { id: 'existing-hd-id', name: 'myapp-hd' }, + ], }, - { runWrangler: run, log: { info: () => {}, warn: (m) => warnings.push(m) } }, ); - expect(calls).toEqual([]); + // No wrangler create — existence via the injected REST seam, id via the writer. + expect(calls.some((c) => isCall(c, 'hyperdrive', 'create'))).toBe(false); + const hd = readConfig().hyperdrive as Array>; + expect(hd).toEqual([{ binding: 'HYPERDRIVE', id: 'existing-hd-id' }]); expect(result.resources).toEqual([ { kind: 'hyperdrive', binding: 'HYPERDRIVE', name: 'myapp-hd', - action: 'skipped', - reason: 'no connection string provided', + action: 'adopted', + id: 'existing-hd-id', }, ]); - expect(warnings.some((w) => w.includes('skipping Hyperdrive'))).toBe(true); }); - it('creates the config with the connection string via --update-config', async () => { + it('creates the config when confirmed absent and a connection string is present', async () => { setup(JSON.stringify({ name: 'app' })); const { run, calls } = makeRunner([ { @@ -591,15 +890,78 @@ describe('provisionCloudflareResources — Hyperdrive', () => { connectionString: 'postgres://u:p@h/db', }, }, - { runWrangler: run, log: silentLog }, + { runWrangler: run, log: silentLog, listHyperdriveConfigs: async () => [] }, ); expect(calls).toEqual([ - `hyperdrive create myapp-hd --connection-string=postgres://u:p@h/db --binding HYPERDRIVE --update-config -c ${wranglerPath}`, + [ + 'hyperdrive', + 'create', + 'myapp-hd', + '--connection-string=postgres://u:p@h/db', + '--binding', + 'HYPERDRIVE', + '--update-config', + '-c', + wranglerPath, + ], ]); expect(result.resources).toEqual([ { kind: 'hyperdrive', binding: 'HYPERDRIVE', name: 'myapp-hd', action: 'created' }, ]); }); + + it('fails closed when existence is ambiguous (check throws) and a connection string is present — no create', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + hyperdrive: { + binding: 'HYPERDRIVE', + name: 'myapp-hd', + connectionString: 'postgres://u:p@h/db', + }, + }, + { + runWrangler: run, + log: silentLog, + listHyperdriveConfigs: async () => { + throw new Error('network down'); + }, + }, + ), + ).rejects.toThrow(/cannot verify whether Hyperdrive config "myapp-hd"/); + expect(calls.some((c) => isCall(c, 'hyperdrive', 'create'))).toBe(false); + }); + + it('skips with a structured reason when no connection string is provided (never prompts, never creates)', async () => { + setup(JSON.stringify({ name: 'app' })); + const warnings: Array = []; + const { run, calls } = makeRunner([]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + hyperdrive: { binding: 'HYPERDRIVE', name: 'myapp-hd', connectionString: '' }, + }, + { runWrangler: run, log: { info: () => {}, warn: (m) => warnings.push(m) } }, + ); + expect(calls).toEqual([]); + expect(result.resources).toEqual([ + { + kind: 'hyperdrive', + binding: 'HYPERDRIVE', + name: 'myapp-hd', + action: 'skipped', + reason: 'no connection string provided', + }, + ]); + expect(warnings.some((w) => w.includes('skipping Hyperdrive'))).toBe(true); + }); }); describe('provisionCloudflareResources — Queues (resource only)', () => { @@ -614,7 +976,7 @@ describe('provisionCloudflareResources — Queues (resource only)', () => { { ...emptyDesired, queues: ['jobs'] }, { runWrangler: run, log: silentLog }, ); - expect(calls).toEqual(['queues info jobs']); + expect(calls).toEqual([['queues', 'info', 'jobs']]); expect(result.resources).toEqual([{ kind: 'queue', name: 'jobs', action: 'reused' }]); // Task 4 must NOT write queue producer/consumer entries into the config. expect(readConfig().queues).toBeUndefined(); @@ -635,7 +997,10 @@ describe('provisionCloudflareResources — Queues (resource only)', () => { { ...emptyDesired, queues: ['emails'] }, { runWrangler: run, log: silentLog }, ); - expect(calls).toEqual(['queues info emails', 'queues create emails']); + expect(calls).toEqual([ + ['queues', 'info', 'emails'], + ['queues', 'create', 'emails'], + ]); expect(result.resources).toEqual([{ kind: 'queue', name: 'emails', action: 'created' }]); expect(readConfig().queues).toBeUndefined(); }); @@ -653,7 +1018,11 @@ describe('provisionCloudflareResources — Queues (resource only)', () => { { ...emptyDesired, queues: ['jobs', 'emails'] }, { runWrangler: run, log: silentLog }, ); - expect(calls).toEqual(['queues info jobs', 'queues info emails', 'queues create emails']); + expect(calls).toEqual([ + ['queues', 'info', 'jobs'], + ['queues', 'info', 'emails'], + ['queues', 'create', 'emails'], + ]); expect(result.resources).toEqual([ { kind: 'queue', name: 'jobs', action: 'reused' }, { kind: 'queue', name: 'emails', action: 'created' }, @@ -676,6 +1045,6 @@ describe('provisionCloudflareResources — Queues (resource only)', () => { { runWrangler: run, log: silentLog }, ), ).rejects.toThrow(/queues info jobs.*failed/s); - expect(calls.some((c) => c.startsWith('queues create'))).toBe(false); + expect(calls.some((c) => isCall(c, 'queues', 'create'))).toBe(false); }); }); diff --git a/packages/void/test/unit/wrangler.test.ts b/packages/void/test/unit/wrangler.test.ts index 3891f497b..d7205faab 100644 --- a/packages/void/test/unit/wrangler.test.ts +++ b/packages/void/test/unit/wrangler.test.ts @@ -2154,4 +2154,94 @@ describe('writeProvisionedWranglerConfig', () => { const hd = parseRaw('wrangler.jsonc').hyperdrive as Array>; expect(hd).toEqual([{ binding: 'HYPERDRIVE', id: 'realhyperid789' }]); }); + + // Adopt-field invariants — the append path must uphold the SAME worker-wide + // binding-uniqueness rules the queue-producer guard enforces, and the update + // path must fail closed on an ambiguous duplicate binding in the target array. + + it('adopt fails closed when the binding is already used by a DIFFERENT resource type (worker-wide collision) — no write', () => { + // A KV is already bound `DB`; adopting a D1 as `DB` would emit a cross-type + // duplicate binding name wrangler rejects at deploy. Fail closed. + setup('wrangler.jsonc', '{ "kv_namespaces": [{ "binding": "DB", "id": "abc" }] }'); + const before = readRaw('wrangler.jsonc'); + expect(() => + writeProvisionedWranglerConfig(tmpDir, { + d1: { binding: 'DB', databaseName: 'app', databaseId: 'real-uuid' }, + }), + ).toThrow(/cannot adopt "DB" into "d1_databases".*already used by a KV Namespace binding/s); + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('adopt fails closed when the binding collides with a var name — no write', () => { + setup('wrangler.jsonc', '{ "vars": { "STORAGE": "x" } }'); + const before = readRaw('wrangler.jsonc'); + expect(() => + writeProvisionedWranglerConfig(tmpDir, { + r2: { binding: 'STORAGE', bucketName: 'uploads' }, + }), + ).toThrow( + /cannot adopt "STORAGE" into "r2_buckets".*already used by an? Environment Variable/s, + ); + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('adopt fails closed when the target array already declares the binding twice (ambiguous) — no write', () => { + setup( + 'wrangler.jsonc', + `{ + "d1_databases": [ + { "binding": "DB", "database_name": "a", "database_id": "id-a" }, + { "binding": "DB", "database_name": "b", "database_id": "id-b" } + ] +}`, + ); + const before = readRaw('wrangler.jsonc'); + expect(() => + writeProvisionedWranglerConfig(tmpDir, { + d1: { binding: 'DB', databaseName: 'app', databaseId: 'real-uuid' }, + }), + ).toThrow(/binding "DB" is declared 2 times in "d1_databases"/); + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('negative control: adopt appends normally when the binding collides with nothing, and is idempotent', () => { + // A D1 binding `DB` and a KV binding `KV` do NOT collide with adopting an R2 + // as `STORAGE` — the worker-wide guard is precise, not paranoid. + setup( + 'wrangler.jsonc', + `{ + "d1_databases": [{ "binding": "DB", "database_name": "app", "database_id": "id" }], + "kv_namespaces": [{ "binding": "KV", "id": "abc" }] +}`, + ); + const patch = { r2: { binding: 'STORAGE', bucketName: 'uploads' } }; + const first = writeProvisionedWranglerConfig(tmpDir, patch); + expect(first.modified).toBe(true); + const r2 = parseRaw('wrangler.jsonc').r2_buckets as Array>; + expect(r2).toEqual([{ binding: 'STORAGE', bucket_name: 'uploads' }]); + + // Idempotent — a second run writes nothing and is byte-identical. + const afterFirst = readRaw('wrangler.jsonc'); + const second = writeProvisionedWranglerConfig(tmpDir, patch); + expect(second.modified).toBe(false); + expect(readRaw('wrangler.jsonc')).toBe(afterFirst); + }); + + it('honors an explicit configPath option (writes the pinned file, not discovery)', () => { + // The provisioner pins the exact file so read-first + wrangler -c + this write + // never diverge. With both files present, discovery would prefer .jsonc; the + // explicit option must win and write .json. + setup('wrangler.jsonc', '{ "name": "jsonc" }'); + writeFileSync(join(tmpDir, 'wrangler.json'), '{ "name": "json" }'); + const jsonPath = join(tmpDir, 'wrangler.json'); + const result = writeProvisionedWranglerConfig( + tmpDir, + { accountId: 'acc-pinned' }, + { configPath: jsonPath }, + ); + expect(result.configPath).toBe(jsonPath); + expect((parseRaw('wrangler.json') as Record).account_id).toBe('acc-pinned'); + // The .jsonc (which discovery would have preferred) is untouched. + expect((parseRaw('wrangler.jsonc') as Record).account_id).toBeUndefined(); + }); }); From 1fda3c2ebb48371369c07adf1c700474904f38ae Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 16 Jul 2026 14:29:28 +0800 Subject: [PATCH 018/128] fix(provision): close 6 codex round-2 findings in the CF resource provisioner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F1 (critical): reconcile one effective Cloudflare account before any read-first/create/REST — fail closed when config.account_id ≠ opts.accountId, pin config.account_id ?? opts.accountId for the wrangler env AND Hyperdrive REST, and pass `-c ` to EVERY wrangler call (list/info/create) so no path targets a different account. F2: paginate the Hyperdrive REST discovery (follow result_info), require success:true + a valid id/name on every entry, fail closed on any malformed/ non-2xx payload, and require exactly 0/1 name match (>1 → fail closed). F3: KV fails closed when a target-title namespace has no valid id (no silent drop→absence); R2 only treats CF code 10006 / the exact "specified bucket does not exist" message as absence, and positively identifies the bucket name before adopting on exit 0. F4: resolve wrangler via module resolution (createRequire/import.meta.resolve of wrangler/package.json, relative to the void package) so strict-pnpm layouts resolve, always launch as [execPath, jsBin], and throw clearly when unresolvable (never a bare/`.cmd` shim). F5: normalizeRunWranglerResult treats a non-numeric status, a non-null signal, or a spawn error as failure — a SIGTERM'd child is no longer reported as success. F6: rebuild the adopt collision guard from the CURRENT config text each adopt, include queues.producers, validate matched (existing-target) entries too, and require exactly one D1 entry for migrations_dir (no findIndex-first). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/cli/preset.ts | 92 +++-- packages/void/src/cli/provision.ts | 232 +++++++++--- packages/void/src/cli/wrangler.ts | 118 ++++-- packages/void/test/unit/preset.test.ts | 44 +++ packages/void/test/unit/provision.test.ts | 417 +++++++++++++++++++--- packages/void/test/unit/wrangler.test.ts | 79 ++++ 6 files changed, 850 insertions(+), 132 deletions(-) diff --git a/packages/void/src/cli/preset.ts b/packages/void/src/cli/preset.ts index 65255955b..39f59e1df 100644 --- a/packages/void/src/cli/preset.ts +++ b/packages/void/src/cli/preset.ts @@ -1,5 +1,7 @@ import { existsSync, readFileSync } from 'node:fs'; -import { join, resolve } from 'pathe'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, resolve } from 'pathe'; import { readProjectPaths } from '../project-paths.ts'; import { getPackageDependencies, @@ -200,32 +202,82 @@ export function resolveWranglerCommand(root: string, subcommand: string): string * a config path containing spaces — can be passed as their own argv elements and * are IMMUNE to shell metacharacter injection/splitting. * - * Resolution order: - * 1. The locally-installed wrangler's JS bin run with the current Node - * (`[process.execPath, ]`). This is - * shell-free AND cross-platform: it sidesteps the Windows `.cmd`/`.ps1` - * shim (which Node cannot spawn with `shell: false`) entirely, so there is - * never a shim to quote or route through `cmd.exe`. - * 2. Fallback to the `node_modules/.bin/wrangler` shim when the package layout - * is non-standard but the shim exists (directly executable on POSIX). - * 3. Fallback to the bare `wrangler` name (resolved via PATH by the OS). + * Resolution order — ALWAYS launches the resolved JS entry as + * `[process.execPath, ]`, which is shell-free AND + * cross-platform (it sidesteps the Windows `.cmd`/`.ps1` shim that Node cannot + * spawn with `shell: false`): + * 1. The app-root `node_modules/wrangler` probe (a consumer may hoist its own + * wrangler at the project root). + * 2. MODULE RESOLUTION of `wrangler/package.json`. wrangler is a dependency of + * the `void` PACKAGE, so under strict pnpm (void + consumers use pnpm) it is + * NOT at the app root — it lives under void's own deps. Resolving relative to + * THIS module finds it wherever pnpm placed it. + * + * If wrangler cannot be resolved by EITHER avenue, throws a clear error — it never + * degrades to a bare `wrangler` (ENOENT in clean CI) or a `.cmd` shim. */ -export function resolveWranglerArgv(root: string): Array { - const jsBin = resolveLocalWranglerJsBin(root); +export function resolveWranglerArgv( + root: string, + resolveWranglerPackageJson: () => string | undefined = defaultResolveWranglerPackageJson, +): Array { + const jsBin = resolveLocalWranglerJsBin(root, resolveWranglerPackageJson); if (jsBin) { return [process.execPath, jsBin]; } - return [localBin(root, 'wrangler')]; + throw new Error( + `provision: could not resolve the "wrangler" package. wrangler ships as a dependency of "void"; ` + + `reinstall dependencies (e.g. \`pnpm install\`) so it is present, then retry.`, + ); +} + +/** + * Resolve `wrangler/package.json` via Node module resolution, relative to THIS + * module (the `void` package), so it is found wherever pnpm placed void's own + * wrangler dependency. wrangler explicitly exports `./package.json`, so both + * `import.meta.resolve` and `createRequire().resolve()` succeed. Returns the + * absolute manifest path, or `undefined` when unresolvable. + */ +function defaultResolveWranglerPackageJson(): string | undefined { + if (typeof import.meta.resolve === 'function') { + try { + return fileURLToPath(import.meta.resolve('wrangler/package.json')); + } catch { + // fall through to createRequire + } + } + try { + return createRequire(import.meta.url).resolve('wrangler/package.json'); + } catch { + return undefined; + } +} + +/** + * Absolute path to the wrangler JS entry (from its `package.json` `bin.wrangler`), + * trying the app-root probe first, then the injected module resolver. Returns + * `undefined` when wrangler cannot be located or its manifest is unreadable. + */ +function resolveLocalWranglerJsBin( + root: string, + resolveWranglerPackageJson: () => string | undefined, +): string | undefined { + const fromRoot = wranglerJsBinFromPackageJson( + join(root, 'node_modules', 'wrangler', 'package.json'), + ); + if (fromRoot) { + return fromRoot; + } + return wranglerJsBinFromPackageJson(resolveWranglerPackageJson()); } /** - * Absolute path to the locally-installed wrangler JS entry (from its - * `package.json` `bin.wrangler`), or `undefined` when wrangler is not installed - * under `root/node_modules` or its manifest is unreadable. + * Read `bin.wrangler` from a wrangler `package.json` at `pkgPath` and resolve it + * to an absolute JS entry (relative to the manifest's own directory, so it works + * for BOTH the app-root probe and module resolution). Returns `undefined` on any + * missing/unreadable/absent-bin case. */ -function resolveLocalWranglerJsBin(root: string): string | undefined { - const pkgPath = join(root, 'node_modules', 'wrangler', 'package.json'); - if (!existsSync(pkgPath)) { +function wranglerJsBinFromPackageJson(pkgPath: string | undefined): string | undefined { + if (!pkgPath || !existsSync(pkgPath)) { return undefined; } try { @@ -236,7 +288,7 @@ function resolveLocalWranglerJsBin(root: string): string | undefined { if (!rel) { return undefined; } - const jsBin = join(root, 'node_modules', 'wrangler', rel); + const jsBin = join(dirname(pkgPath), rel); return existsSync(jsBin) ? jsBin : undefined; } catch { return undefined; diff --git a/packages/void/src/cli/provision.ts b/packages/void/src/cli/provision.ts index 46abcdf28..13118e984 100644 --- a/packages/void/src/cli/provision.ts +++ b/packages/void/src/cli/provision.ts @@ -152,14 +152,48 @@ export function createDefaultRunWrangler( stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }); - return { - stdout: result.stdout ?? '', - stderr: result.stderr ?? '', - status: result.status ?? (result.error ? 1 : 0), - }; + return normalizeRunWranglerResult(result); }; } +/** + * Normalize a `spawnSync` result into a {@link RunWranglerResult}, treating a + * signal-killed child or a spawn error as a FAILURE — never success. + * + * The naive `status ?? (error ? 1 : 0)` collapses a SIGTERM'd child (which + * returns `status: null`, a non-null `signal`, and NO `error`) to `0`, so a + * killed `create` would be recorded as `created` though nothing happened. So we + * fail on ANY non-numeric `status`, a non-null `signal`, or a present `error`, + * and surface the signal/spawn-error text in `stderr` for diagnostics. + */ +export function normalizeRunWranglerResult(result: { + stdout?: string | null; + stderr?: string | null; + status?: number | null; + signal?: NodeJS.Signals | null; + error?: Error; +}): RunWranglerResult { + const stdout = result.stdout ?? ''; + const notes: Array = []; + if (result.signal != null) { + notes.push(`provision: wrangler was terminated by signal ${result.signal}`); + } + if (result.error != null) { + notes.push(`provision: wrangler could not be spawned: ${result.error.message}`); + } + const failed = typeof result.status !== 'number' || result.signal != null || result.error != null; + // A failure must be non-zero. Keep a genuine non-zero exit code when present; + // otherwise synthesize `1` (a null/absent status under a signal/spawn error). + const status = failed + ? typeof result.status === 'number' && result.status !== 0 + ? result.status + : 1 + : (result.status as number); + const baseStderr = result.stderr ?? ''; + const stderr = notes.length > 0 ? [baseStderr, ...notes].filter(Boolean).join('\n') : baseStderr; + return { stdout, stderr, status }; +} + /** * Default Hyperdrive existence check: `GET /accounts/{id}/hyperdrive/configs` * with the CF API token, matched by name in the caller. Returns `undefined` when @@ -175,23 +209,50 @@ function createDefaultListHyperdriveConfigs( return undefined; } return async () => { - const res = await fetch( - `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/hyperdrive/configs`, - { headers: { Authorization: `Bearer ${token}` } }, - ); - if (!res.ok) { - throw new Error(`Cloudflare API returned HTTP ${res.status} listing Hyperdrive configs`); - } - const body = (await res.json()) as { result?: unknown }; - if (!isRecord(body) || !Array.isArray(body.result)) { - throw new Error('Cloudflare API returned an unexpected Hyperdrive configs payload'); + // Fetch ALL pages (Hyperdrive names are NOT unique — paid accounts allow + // ~25 configs, paginated) and FAIL CLOSED on any non-2xx, `success !== true`, + // or a malformed entry. Never coerce a malformed envelope/entry to "absence": + // that would mint a duplicate config on a re-run. + const perPage = 100; + const all: Array = []; + for (let page = 1; ; page++) { + const res = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/hyperdrive/configs?page=${page}&per_page=${perPage}`, + { headers: { Authorization: `Bearer ${token}` } }, + ); + if (!res.ok) { + throw new Error(`Cloudflare API returned HTTP ${res.status} listing Hyperdrive configs`); + } + const body = (await res.json()) as Record; + if (!isRecord(body) || body.success !== true || !Array.isArray(body.result)) { + throw new Error( + 'Cloudflare API returned an unsuccessful or malformed Hyperdrive configs payload', + ); + } + for (const entry of body.result) { + if ( + !isRecord(entry) || + typeof entry.id !== 'string' || + entry.id.length === 0 || + typeof entry.name !== 'string' || + entry.name.length === 0 + ) { + throw new Error( + 'Cloudflare API returned a malformed Hyperdrive config entry (missing id/name)', + ); + } + all.push({ id: entry.id, name: entry.name }); + } + // Follow `result_info` pagination: stop once we have gathered `total_count` + // entries (or the page came back empty / without pagination metadata). + const info = body.result_info; + const totalCount = + isRecord(info) && typeof info.total_count === 'number' ? info.total_count : undefined; + if (totalCount === undefined || body.result.length === 0 || all.length >= totalCount) { + break; + } } - return body.result - .filter( - (c): c is { id: string; name: string } => - isRecord(c) && typeof c.id === 'string' && typeof c.name === 'string', - ) - .map((c) => ({ id: c.id, name: c.name })); + return all; }; } @@ -294,15 +355,34 @@ function extractD1( return { id, name: typeof entry.name === 'string' ? entry.name : undefined }; } -/** `wrangler kv namespace list` → `[{ id, title }]` (already parsed + validated). */ +/** + * `wrangler kv namespace list` → the `{ id }` of every namespace whose title + * matches. FAILS CLOSED: a namespace whose title matches the target but carries + * no non-empty string `id` (a schema-valid-but-idless member like + * `{ "title": "target" }`) is NOT silently dropped — dropping it would look like + * "absent" and mint a DUPLICATE namespace (KV titles are not account-unique). It + * throws so the caller surfaces the ambiguity instead of blindly creating. + */ function extractKvMatches( list: Array>, title: string, -): Array<{ id: string; title: string }> { - return list - .filter((ns) => ns.title === title) - .map((ns) => ({ id: String(ns.id ?? ''), title })) - .filter((ns) => ns.id.length > 0); +): Array<{ id: string }> { + const matches: Array<{ id: string }> = []; + for (const ns of list) { + if (ns.title !== title) { + continue; + } + const id = ns.id; + if (typeof id !== 'string' || id.length === 0) { + throw new Error( + `provision: "wrangler kv namespace list" returned a namespace titled ${JSON.stringify(title)} ` + + `with no valid id. Cannot verify whether it already exists; refusing to create a possible ` + + `duplicate (KV titles are not account-unique). Inspect the account and retry.`, + ); + } + matches.push({ id }); + } + return matches; } /** True when a `d1 create` failure means the name is already taken (code 7502). */ @@ -324,15 +404,19 @@ function isMissingResourceError(result: RunWranglerResult): boolean { /** * True ONLY when a failing `r2 bucket info` is the confirmed bucket-not-found - * signal (CF API code `10006` / a not-found message). In wrangler 4.107 `r2 - * bucket info` does the bucket lookup AND a separate metrics/GraphQL query, so an - * EXISTING bucket can exit non-zero on a metrics/auth/network failure. Creating - * on any non-zero would then error on the existing name (spurious failure) or, in - * the worst case, risk a duplicate — so we create ONLY on this confirmed signal. + * signal. The DEFINITIVE signal is CF API error code `10006` — the exact code + * wrangler's own `isConnectedToExistingResource` treats as "bucket absent" — plus + * wrangler's exact R2 not-found message. In wrangler 4.107 `r2 bucket info` does + * the bucket lookup AND a separate metrics/GraphQL query, so an EXISTING bucket + * can exit non-zero on a metrics/auth/network failure. We deliberately do NOT + * fall back to the broad `isMissingResourceError` regex (`not found`/`no such`/…): + * an unrelated metrics/GraphQL error whose text loosely contains "not found" + * would be misread as absence and mint a duplicate. Create ONLY on this confirmed + * signal; any other non-zero is surfaced. */ function isBucketNotFoundError(result: RunWranglerResult): boolean { const text = `${result.stdout}\n${result.stderr}`; - return text.includes('10006') || isMissingResourceError(result); + return /\b10006\b/.test(text) || /the specified bucket does not exist/i.test(text); } /** @@ -382,8 +466,34 @@ export async function provisionCloudflareResources( // ONE exact config path threaded through read-first, wrangler `-c`, AND the // writer, so reads and writes can never hit different files (no split-brain). const configPath = rootWranglerPath; + // Resolve the ONE effective Cloudflare account BEFORE any read-first / create / + // REST. wrangler 4.107 gives the resolved config's `account_id` PRECEDENCE over + // the CLOUDFLARE_ACCOUNT_ID env var, so if the config pins account X while + // `opts.accountId` requests Y, wrangler create/list would target X while the + // Hyperdrive REST existence check targets Y — a cross-account create/orphan. + // Fail closed on a real mismatch; otherwise pin `config.account_id ?? + // opts.accountId` EVERYWHERE (the wrangler env AND the Hyperdrive REST call), so + // every path targets the same account. + const configAccountId = readRootConfig(configPath).account_id; + if ( + typeof configAccountId === 'string' && + configAccountId.length > 0 && + opts?.accountId && + configAccountId !== opts.accountId + ) { + throw new Error( + `provision: Cloudflare account mismatch — "${configPath}" pins account_id ` + + `${JSON.stringify(configAccountId)} but deploy requested ${JSON.stringify(opts.accountId)}. ` + + `Reconcile them (correct or remove the config's account_id, or deploy to the pinned account) ` + + `and retry.`, + ); + } + const effectiveAccountId = + typeof configAccountId === 'string' && configAccountId.length > 0 + ? configAccountId + : opts?.accountId; const runWrangler = - opts?.runWrangler ?? createDefaultRunWrangler(root, { accountId: opts?.accountId }); + opts?.runWrangler ?? createDefaultRunWrangler(root, { accountId: effectiveAccountId }); const log = opts?.log ?? defaultLogger; const resources: Array = []; const write = ( @@ -404,9 +514,9 @@ export async function provisionCloudflareResources( resources.push({ kind: 'd1', binding, name, action: 'reused', id: entry.database_id }); } else { const existing = extractD1( - parseJsonArray(runWrangler(['d1', 'list', '--json'], { captureJson: true }).stdout).find( - (db) => db.name === name, - ), + parseJsonArray( + runWrangler(['d1', 'list', '--json', '-c', configPath], { captureJson: true }).stdout, + ).find((db) => db.name === name), ); if (existing) { log.info(`adopted existing D1 database ${name} (${existing.id})`); @@ -433,7 +543,8 @@ export async function provisionCloudflareResources( // the id via `d1 info` and adopt instead of failing. const info = extractD1( parseJsonObject( - runWrangler(['d1', 'info', name, '--json'], { captureJson: true }).stdout, + runWrangler(['d1', 'info', name, '--json', '-c', configPath], { captureJson: true }) + .stdout, ), ); if (!info) { @@ -477,7 +588,9 @@ export async function provisionCloudflareResources( // JSON array before treating an absent title as authoritative — otherwise a // nonzero/auth/network/truncated result would parse as "absent" and mint a // duplicate namespace. Fail closed (throw) instead. - const listResult = runWrangler(['kv', 'namespace', 'list'], { captureJson: true }); + const listResult = runWrangler(['kv', 'namespace', 'list', '-c', configPath], { + captureJson: true, + }); if (listResult.status !== 0) { throw new Error( `provision: "wrangler kv namespace list" failed: ${listResult.stderr || listResult.stdout}. ` + @@ -537,10 +650,21 @@ export async function provisionCloudflareResources( } else { // `wrangler r2 bucket list` is a human table, not JSON. Bucket names ARE // account-unique, so probe the single bucket by name. - const info = runWrangler(['r2', 'bucket', 'info', bucketName, '--json'], { + const info = runWrangler(['r2', 'bucket', 'info', bucketName, '--json', '-c', configPath], { captureJson: true, }); if (info.status === 0) { + // Exit 0 is not enough to adopt — the success payload must POSITIVELY + // identify the requested bucket (its `name`) before we write the entry, + // else a mismatched/garbled 0-exit payload could bind the wrong bucket. + const payload = parseJsonObject(info.stdout); + if (!payload || payload.name !== bucketName) { + throw new Error( + `provision: "wrangler r2 bucket info ${bucketName} --json" exited 0 but did not identify ` + + `the requested bucket (expected name ${JSON.stringify(bucketName)}). Refusing to adopt an ` + + `unverified bucket. Output: ${info.stdout || info.stderr}`, + ); + } // Adopt it — write the `{binding,bucket_name}` entry via the writer. log.info(`adopted existing R2 bucket ${bucketName}`); write({ r2: { binding, bucketName } }); @@ -593,19 +717,35 @@ export async function provisionCloudflareResources( } else { const lister = opts?.listHyperdriveConfigs ?? - createDefaultListHyperdriveConfigs(opts?.accountId, opts?.apiToken); - let existing: HyperdriveConfig | undefined; + createDefaultListHyperdriveConfigs(effectiveAccountId, opts?.apiToken); + let configs: Array | undefined; let existenceUnknown = true; if (lister) { try { - const configs = await lister(); - existing = configs.find((c) => c.name === name); + configs = await lister(); existenceUnknown = false; } catch (error) { log.warn(`Hyperdrive existence check for ${name} failed: ${(error as Error).message}`); existenceUnknown = true; } } + // Require EXACTLY 0 or 1 exact-name match. Hyperdrive names are NOT unique, + // so >1 is ambiguous — adoption cannot pick one, and a blind create would + // mint yet another duplicate. Fail closed on ambiguity (regardless of the + // connection string). + let existing: HyperdriveConfig | undefined; + if (configs) { + const named = configs.filter((c) => c.name === name); + if (named.length > 1) { + throw new Error( + `provision: cannot adopt Hyperdrive config ${JSON.stringify(name)} — ${named.length} ` + + `configs in this account share that name (ids: ${named.map((c) => c.id).join(', ')}). ` + + `Hyperdrive names are not account-unique; remove the duplicates or pin the id in ` + + `"${configPath}", then retry.`, + ); + } + existing = named[0]; + } if (existing) { log.info(`adopted existing Hyperdrive config ${name} (${existing.id})`); write({ hyperdrive: { binding, id: existing.id } }); @@ -656,7 +796,7 @@ export async function provisionCloudflareResources( // queue RESOURCE exists — binding the producers/consumers into the config is // app-class-aware and belongs to Task 6. for (const name of desired.queues) { - const info = runWrangler(['queues', 'info', name]); + const info = runWrangler(['queues', 'info', name, '-c', configPath]); if (info.status === 0) { resources.push({ kind: 'queue', name, action: 'reused' }); continue; @@ -666,7 +806,7 @@ export async function provisionCloudflareResources( `provision: "wrangler queues info ${name}" failed: ${info.stderr || info.stdout}`, ); } - const created = runWrangler(['queues', 'create', name]); + const created = runWrangler(['queues', 'create', name, '-c', configPath]); if (created.status !== 0) { throw new Error( `provision: "wrangler queues create ${name}" failed: ${created.stderr || created.stdout}`, diff --git a/packages/void/src/cli/wrangler.ts b/packages/void/src/cli/wrangler.ts index 581b4101c..717aa41ae 100644 --- a/packages/void/src/cli/wrangler.ts +++ b/packages/void/src/cli/wrangler.ts @@ -899,9 +899,11 @@ export function writeProvisionedWranglerConfig( } // Lazily-built map of EVERY binding name already used anywhere in the config - // (keyed name → wrangler's friendly owner label). Shared by the adopt-append - // guard below and the queue-producer append guard further down; it is keyed on - // the initial `parsed` (constant within this call), so memoizing once is safe. + // (keyed name → wrangler's friendly owner label). Used by the queue-producer + // append guard further down; built from the CURRENT `text` at first use so it + // reflects any adopt appended earlier in this call (not the stale initial + // `parsed`). The adopt guard below rebuilds its own view per adopt (it must + // include queues.producers, which this collector deliberately omits). let workerBindingOwners: Map | undefined; // Adopt fields — write the REAL id/name of an already-existing resource into @@ -915,15 +917,20 @@ export function writeProvisionedWranglerConfig( // enforces (see `validateBindingsHaveUniqueNames` mirror below): // - >1 entry in the target array sharing `binding` → fail closed (ambiguous: // adoption cannot pick which to write, and wrangler rejects the duplicate). - // - APPENDING a new entry whose binding is already used by ANOTHER binding - // type (a KV bound `DB`, a var, a secret, …) → fail closed (a cross-type - // duplicate binding name wrangler rejects at deploy). + // - The binding is already used by ANOTHER binding type — a KV bound `DB`, a + // var, a secret, a QUEUE PRODUCER, … — whether we are APPENDING a new entry + // OR UPDATING an existing matched one → fail closed (a cross-type duplicate + // binding name wrangler rejects at deploy). const upsertAdoptedBinding = ( field: string, binding: string, properties: Record, ): void => { - const rawList = parseJsonc(text)[field]; + // Re-parse the CURRENT text (not the initial `parsed`) so this sees any adopt + // appended earlier in THIS call — a stale map would let a multi-field patch + // adopt the same binding into two arrays without noticing the collision. + const currentConfig = parseJsonc(text); + const rawList = currentConfig[field]; const currentList = Array.isArray(rawList) ? [...rawList] : []; const matches: Array = []; for (let i = 0; i < currentList.length; i++) { @@ -939,6 +946,25 @@ export function writeProvisionedWranglerConfig( `real id into, and wrangler rejects duplicate binding names. Remove the duplicate and retry.`, ); } + // Worker-wide cross-type uniqueness — checked for BOTH the matched (existing + // target) and the append case: a binding shared with any OTHER binding type is + // undeployable regardless of whether we create or update this entry. Build the + // owner map from the CURRENT config, EXCLUDING the target field (its own + // matched entry must not self-collide) and INCLUDING queues.producers (the base + // collector omits producers so the producer path does not self-collide, but for + // an adopt a producer sharing the binding IS a real cross-type collision). + // Reuse the shared collector — do NOT fork it. + const owners = collectWorkerBindingNames(configWithoutField(currentConfig, field)); + addQueueProducerOwners(owners, currentConfig); + const owner = owners.get(binding); + if (owner !== undefined) { + throw new Error( + `deploy: cannot update "${configPath}" — cannot adopt ${JSON.stringify(binding)} into ` + + `"${field}" because that binding name is already used by a ${owner} binding. Wrangler ` + + `requires every binding name to be unique across the whole worker, so this would make ` + + `the config undeployable. Rename the conflicting binding and retry.`, + ); + } if (matches.length === 1) { const index = matches[0]; const existing = isRecord(currentList[index]) ? currentList[index] : {}; @@ -948,22 +974,6 @@ export function writeProvisionedWranglerConfig( } } } else { - // APPEND branch only (no entry in this array owns the binding). wrangler - // requires every binding NAME to be unique across the ENTIRE worker, so - // before appending, fail closed if the binding is already used by any OTHER - // binding type. Reuse the same worker-wide collector the producer guard - // uses (do NOT fork it) — a cross-type collision here yields a config - // wrangler rejects at deploy. - workerBindingOwners ??= collectWorkerBindingNames(parsed); - const owner = workerBindingOwners.get(binding); - if (owner !== undefined) { - throw new Error( - `deploy: cannot update "${configPath}" — cannot adopt ${JSON.stringify(binding)} into ` + - `"${field}" because that binding name is already used by a ${owner} binding. Wrangler ` + - `requires every binding name to be unique across the whole worker, so this would make ` + - `the config undeployable. Rename the conflicting binding and retry.`, - ); - } text = applyMemberEdit( text, [field, currentList.length], @@ -1001,7 +1011,25 @@ export function writeProvisionedWranglerConfig( const d1Entries = Array.isArray(currentD1) ? (currentD1 as Array<{ binding?: unknown; migrations_pattern?: unknown }>) : []; - const index = d1Entries.findIndex((entry) => entry.binding === patch.d1MigrationsDir!.binding); + // Require EXACTLY ONE D1 entry with the binding. `findIndex` (first match) + // would silently accept DUPLICATE D1 bindings — a config wrangler rejects — + // and attach migrations_dir to only the first. Fail closed on duplicates. + const matchIndices: Array = []; + d1Entries.forEach((entry, i) => { + if (entry.binding === patch.d1MigrationsDir!.binding) { + matchIndices.push(i); + } + }); + if (matchIndices.length > 1) { + throw new Error( + `deploy: cannot update "${configPath}" — binding ` + + `${JSON.stringify(patch.d1MigrationsDir.binding)} is declared ${matchIndices.length} times ` + + `in "d1_databases". A binding name must be unique across the worker (wrangler rejects the ` + + `config otherwise), so migrations_dir cannot be attached unambiguously. Remove the ` + + `duplicate D1 entry and retry.`, + ); + } + const index = matchIndices.length === 1 ? matchIndices[0] : -1; if (index >= 0) { // A companion `migrations_pattern` (if the user set one) must stay valid // under the NEW `migrations_dir`: wrangler requires the normalized pattern @@ -1115,8 +1143,9 @@ export function writeProvisionedWranglerConfig( // emitting it would produce a config wrangler rejects at deploy // (cross-type duplicate binding name). A same-queue producer already // owning that binding is a no-op handled above (index >= 0), never a - // collision. - workerBindingOwners ??= collectWorkerBindingNames(parsed); + // collision. Build from the CURRENT text so an adopt appended earlier in + // this call is seen too (not the stale initial `parsed`). + workerBindingOwners ??= collectWorkerBindingNames(parseJsonc(text)); const bindingName = String(entry.binding); const owner = workerBindingOwners.get(bindingName); if (owner !== undefined) { @@ -1609,6 +1638,43 @@ function collectWorkerBindingNames(config: Record): Map, + field: string, +): Record { + const { [field]: _omit, ...rest } = config; + return rest; +} + +/** + * Add every `queues.producers[].binding` to an existing worker-binding owner map, + * under the friendly label "Queue" (first-writer-wins). The base + * `collectWorkerBindingNames` deliberately OMITS queues so the producer-append + * path does not flag the producer it is adding as a self-collision — but for the + * ADOPT path a producer binding sharing the adopted binding IS a real cross-type + * collision wrangler rejects at deploy, so the adopt guard layers producers on + * top of the reused collector. + */ +function addQueueProducerOwners( + owners: Map, + config: Record, +): void { + const queues = config.queues; + if (!isRecord(queues) || !Array.isArray(queues.producers)) { + return; + } + for (const producer of queues.producers) { + if ( + isRecord(producer) && + typeof producer.binding === 'string' && + !owners.has(producer.binding) + ) { + owners.set(producer.binding, 'Queue'); + } + } +} + /** * Binding-name arrays whose entries key the binding name on `.name`, NOT * `.binding`. wrangler's `getBindingNames` only reads `.binding` from array diff --git a/packages/void/test/unit/preset.test.ts b/packages/void/test/unit/preset.test.ts index b912eda0c..3e60688fc 100644 --- a/packages/void/test/unit/preset.test.ts +++ b/packages/void/test/unit/preset.test.ts @@ -6,6 +6,7 @@ import { detectPreset, formatProjectCommand, resolveProjectCommand, + resolveWranglerArgv, resolveWranglerCommand, } from '../../src/cli/preset.ts'; @@ -279,6 +280,49 @@ describe('resolveWranglerCommand', () => { }); }); +describe('resolveWranglerArgv (module-resolution launcher)', () => { + it('resolves wrangler via module resolution to [execPath, jsBin] even when the app root has no wrangler (strict pnpm)', () => { + // wrangler is a dependency of the `void` package, NOT the app root under strict + // pnpm. Module resolution (relative to preset.ts) still finds it. + const root = setupProject({ pkg: { name: 'app' } }); + const argv = resolveWranglerArgv(root); + expect(argv).toHaveLength(2); + expect(argv[0]).toBe(process.execPath); + // The resolved entry is wrangler's package-owned JS bin. + expect(argv[1].replace(/\\/g, '/')).toMatch(/wrangler\/bin\/wrangler\.js$/); + }); + + it('prefers the app-root wrangler package.json when present, launched via [execPath, jsBin]', () => { + const root = setupProject({ + pkg: { dependencies: { wrangler: '^4.0.0' } }, + files: { + 'node_modules/wrangler/package.json': JSON.stringify({ + bin: { wrangler: './bin/wrangler.js' }, + }), + 'node_modules/wrangler/bin/wrangler.js': '#!/usr/bin/env node\n', + }, + }); + const argv = resolveWranglerArgv(root); + expect(argv).toEqual([ + process.execPath, + join(root, 'node_modules', 'wrangler', 'bin', 'wrangler.js'), + ]); + }); + + it('never yields a .bin shim argv and throws clearly when wrangler cannot be resolved', () => { + // A shim/`.cmd`-only layout with module resolution failing must THROW — never + // degrade to a bare `wrangler` (ENOENT in CI) or a shim Node cannot spawn with + // shell:false. + const root = setupProject({ + pkg: { name: 'app' }, + files: { 'node_modules/.bin/wrangler': '#!/bin/sh\n' }, + }); + expect(() => resolveWranglerArgv(root, () => undefined)).toThrow( + /could not resolve the "wrangler" package/, + ); + }); +}); + describe('resolveProjectCommand shell safety (project root with spaces)', () => { // A dedicated root whose path contains a space + a single quote — both are // shell-significant and must be quoted before the string reaches execSync. diff --git a/packages/void/test/unit/provision.test.ts b/packages/void/test/unit/provision.test.ts index d3ae2d363..8815bcced 100644 --- a/packages/void/test/unit/provision.test.ts +++ b/packages/void/test/unit/provision.test.ts @@ -3,6 +3,7 @@ import { parse as parseJsoncRaw } from 'jsonc-parser'; import { join } from 'pathe'; import { afterEach, describe, expect, it } from 'vite-plus/test'; import { + normalizeRunWranglerResult, provisionCloudflareResources, type DesiredResourceSet, type RunWrangler, @@ -107,7 +108,7 @@ describe('provisionCloudflareResources — read-first', () => { }), ); const { run, calls } = makeRunner([ - { match: /^d1 list --json$/, reply: () => ok('[]') }, + { match: /^d1 list --json/, reply: () => ok('[]') }, { match: /^d1 create /, reply: () => { @@ -126,7 +127,7 @@ describe('provisionCloudflareResources — read-first', () => { { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, { runWrangler: run, log: silentLog }, ); - expect(calls[0]).toEqual(['d1', 'list', '--json']); + expect(calls[0]).toEqual(['d1', 'list', '--json', '-c', wranglerPath]); expect(result.resources[0].action).toBe('created'); }); }); @@ -179,7 +180,7 @@ describe('provisionCloudflareResources — argv injection safety', () => { writeFileSync(spacedPath, JSON.stringify({ name: 'app' })); try { const { run, calls } = makeRunner([ - { match: /^d1 list --json$/, reply: () => ok('[]') }, + { match: /^d1 list --json/, reply: () => ok('[]') }, { match: /^d1 create /, reply: () => { @@ -266,7 +267,7 @@ describe('provisionCloudflareResources — config discovery (fail closed, single writeFileSync(jsoncPath, JSON.stringify({ kv_namespaces: [{ binding: 'KV', id: 'local' }] })); const { run } = makeRunner([ { - match: /^kv namespace list$/, + match: /^kv namespace list/, reply: () => ok(JSON.stringify([{ id: 'real-kv-id', title: 'myapp-kv' }])), }, ]); @@ -290,7 +291,7 @@ describe('provisionCloudflareResources — D1', () => { it('creates a missing D1 with --update-config then writes migrations_dir', async () => { setup(JSON.stringify({ name: 'app' })); const { run, calls } = makeRunner([ - { match: /^d1 list --json$/, reply: () => ok('[]') }, + { match: /^d1 list --json/, reply: () => ok('[]') }, { match: /^d1 create /, reply: () => { @@ -310,7 +311,7 @@ describe('provisionCloudflareResources — D1', () => { { runWrangler: run, log: silentLog }, ); expect(calls).toEqual([ - ['d1', 'list', '--json'], + ['d1', 'list', '--json', '-c', wranglerPath], ['d1', 'create', 'myapp-db', '--binding', 'DB', '--update-config', '-c', wranglerPath], ]); const d1 = readConfig().d1_databases as Array>; @@ -365,7 +366,7 @@ describe('provisionCloudflareResources — D1', () => { ); const { run, calls } = makeRunner([ { - match: /^d1 list --json$/, + match: /^d1 list --json/, reply: () => ok(JSON.stringify([{ uuid: 'existing-uuid', name: 'myapp-db' }])), }, ]); @@ -375,7 +376,7 @@ describe('provisionCloudflareResources — D1', () => { { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, { runWrangler: run, log: silentLog }, ); - expect(calls).toEqual([['d1', 'list', '--json']]); + expect(calls).toEqual([['d1', 'list', '--json', '-c', wranglerPath]]); expect(calls.some((c) => isCall(c, 'd1', 'create'))).toBe(false); const d1 = readConfig().d1_databases as Array>; expect(d1).toEqual([ @@ -398,13 +399,13 @@ describe('provisionCloudflareResources — D1', () => { }), ); const { run, calls } = makeRunner([ - { match: /^d1 list --json$/, reply: () => ok('[]') }, + { match: /^d1 list --json/, reply: () => ok('[]') }, { match: /^d1 create /, reply: () => fail('', 'X [ERROR] A database with that name already exists [code: 7502]'), }, { - match: /^d1 info myapp-db --json$/, + match: /^d1 info myapp-db --json/, reply: () => ok(JSON.stringify({ uuid: 'raced-uuid', name: 'myapp-db' })), }, ]); @@ -415,9 +416,9 @@ describe('provisionCloudflareResources — D1', () => { { runWrangler: run, log: silentLog }, ); expect(calls).toEqual([ - ['d1', 'list', '--json'], + ['d1', 'list', '--json', '-c', wranglerPath], ['d1', 'create', 'myapp-db', '--binding', 'DB', '--update-config', '-c', wranglerPath], - ['d1', 'info', 'myapp-db', '--json'], + ['d1', 'info', 'myapp-db', '--json', '-c', wranglerPath], ]); const d1 = readConfig().d1_databases as Array>; expect(d1[0]).toMatchObject({ binding: 'DB', database_id: 'raced-uuid' }); @@ -433,7 +434,7 @@ describe('provisionCloudflareResources — D1', () => { it('throws on a non-7502 create failure', async () => { setup(JSON.stringify({ name: 'app' })); const { run } = makeRunner([ - { match: /^d1 list --json$/, reply: () => ok('[]') }, + { match: /^d1 list --json/, reply: () => ok('[]') }, { match: /^d1 create /, reply: () => fail('', 'X [ERROR] Authentication error [code: 10000]'), @@ -455,7 +456,7 @@ describe('provisionCloudflareResources — KV', () => { setup(JSON.stringify({ kv_namespaces: [{ binding: 'KV', id: 'local' }] })); const { run, calls } = makeRunner([ { - match: /^kv namespace list$/, + match: /^kv namespace list/, reply: () => ok(JSON.stringify([{ id: 'real-kv-id', title: 'myapp-kv' }])), }, ]); @@ -465,7 +466,7 @@ describe('provisionCloudflareResources — KV', () => { { ...emptyDesired, kvNamespaces: [{ binding: 'KV', title: 'myapp-kv' }] }, { runWrangler: run, log: silentLog }, ); - expect(calls).toEqual([['kv', 'namespace', 'list']]); + expect(calls).toEqual([['kv', 'namespace', 'list', '-c', wranglerPath]]); const kv = readConfig().kv_namespaces as Array>; expect(kv).toEqual([{ binding: 'KV', id: 'real-kv-id' }]); expect(result.resources).toEqual([ @@ -476,7 +477,7 @@ describe('provisionCloudflareResources — KV', () => { it('creates when the namespace list is empty', async () => { setup(JSON.stringify({ name: 'app' })); const { run, calls } = makeRunner([ - { match: /^kv namespace list$/, reply: () => ok('[]') }, + { match: /^kv namespace list/, reply: () => ok('[]') }, { match: /^kv namespace create /, reply: () => { @@ -492,7 +493,7 @@ describe('provisionCloudflareResources — KV', () => { { runWrangler: run, log: silentLog }, ); expect(calls).toEqual([ - ['kv', 'namespace', 'list'], + ['kv', 'namespace', 'list', '-c', wranglerPath], [ 'kv', 'namespace', @@ -514,7 +515,7 @@ describe('provisionCloudflareResources — KV', () => { setup(JSON.stringify({ name: 'app' })); const { run, calls } = makeRunner([ { - match: /^kv namespace list$/, + match: /^kv namespace list/, reply: () => fail('', 'X [ERROR] Authentication error [code: 10000]'), }, ]); @@ -532,7 +533,7 @@ describe('provisionCloudflareResources — KV', () => { it('fails closed (no create) when `kv namespace list` returns non-JSON output', async () => { setup(JSON.stringify({ name: 'app' })); const { run, calls } = makeRunner([ - { match: /^kv namespace list$/, reply: () => ok('not a json list at all') }, + { match: /^kv namespace list/, reply: () => ok('not a json list at all') }, ]); await expect( provisionCloudflareResources( @@ -548,7 +549,7 @@ describe('provisionCloudflareResources — KV', () => { it('fails closed (no create) when `kv namespace list` output is truncated', async () => { setup(JSON.stringify({ name: 'app' })); const { run, calls } = makeRunner([ - { match: /^kv namespace list$/, reply: () => ok('[{ "id": "a", "title": "myapp-kv"') }, + { match: /^kv namespace list/, reply: () => ok('[{ "id": "a", "title": "myapp-kv"') }, ]); await expect( provisionCloudflareResources( @@ -566,7 +567,7 @@ describe('provisionCloudflareResources — KV', () => { const warnings: Array = []; const { run } = makeRunner([ { - match: /^kv namespace list$/, + match: /^kv namespace list/, reply: () => ok( JSON.stringify([ @@ -593,7 +594,7 @@ describe('provisionCloudflareResources — KV', () => { it('provisions each KV binding independently (ISR_CACHE + app KV)', async () => { setup(JSON.stringify({ name: 'app' })); const { run, calls } = makeRunner([ - { match: /^kv namespace list$/, reply: () => ok('[]') }, + { match: /^kv namespace list/, reply: () => ok('[]') }, { match: /^kv namespace create /, reply: (sub) => { @@ -652,7 +653,7 @@ describe('provisionCloudflareResources — idempotent re-run', () => { setup(JSON.stringify({ kv_namespaces: [{ binding: 'KV', id: 'local' }] })); const first = makeRunner([ { - match: /^kv namespace list$/, + match: /^kv namespace list/, reply: () => ok(JSON.stringify([{ id: 'real-kv-id', title: 'myapp-kv' }])), }, ]); @@ -699,7 +700,7 @@ describe('provisionCloudflareResources — R2', () => { setup(JSON.stringify({ name: 'app' })); const { run, calls } = makeRunner([ { - match: /^r2 bucket info myapp-uploads --json$/, + match: /^r2 bucket info myapp-uploads --json/, reply: () => ok(JSON.stringify({ name: 'myapp-uploads', created: 'x' })), }, ]); @@ -709,7 +710,9 @@ describe('provisionCloudflareResources — R2', () => { { ...emptyDesired, r2: { binding: 'STORAGE', bucketName: 'myapp-uploads' } }, { runWrangler: run, log: silentLog }, ); - expect(calls).toEqual([['r2', 'bucket', 'info', 'myapp-uploads', '--json']]); + expect(calls).toEqual([ + ['r2', 'bucket', 'info', 'myapp-uploads', '--json', '-c', wranglerPath], + ]); expect(readConfig().r2_buckets).toEqual([{ binding: 'STORAGE', bucket_name: 'myapp-uploads' }]); expect(result.resources).toEqual([ { kind: 'r2', binding: 'STORAGE', name: 'myapp-uploads', action: 'adopted' }, @@ -741,7 +744,7 @@ describe('provisionCloudflareResources — R2', () => { { runWrangler: run, log: silentLog }, ); expect(calls).toEqual([ - ['r2', 'bucket', 'info', 'myapp-uploads', '--json'], + ['r2', 'bucket', 'info', 'myapp-uploads', '--json', '-c', wranglerPath], [ 'r2', 'bucket', @@ -805,7 +808,9 @@ describe('provisionCloudflareResources — R2', () => { ), ).rejects.toThrow(/r2 bucket info myapp-uploads.*(failed|refusing)/s); // No create attempted (the existing bucket must not be duplicated/errored on). - expect(calls).toEqual([['r2', 'bucket', 'info', 'myapp-uploads', '--json']]); + expect(calls).toEqual([ + ['r2', 'bucket', 'info', 'myapp-uploads', '--json', '-c', wranglerPath], + ]); }); }); @@ -968,7 +973,7 @@ describe('provisionCloudflareResources — Queues (resource only)', () => { it('reuses a queue that `queues info` finds (no create, never writes queue config)', async () => { setup(JSON.stringify({ name: 'app' })); const { run, calls } = makeRunner([ - { match: /^queues info jobs$/, reply: () => ok('Queue Name: jobs') }, + { match: /^queues info jobs/, reply: () => ok('Queue Name: jobs') }, ]); const result = await provisionCloudflareResources( tmpDir, @@ -976,7 +981,7 @@ describe('provisionCloudflareResources — Queues (resource only)', () => { { ...emptyDesired, queues: ['jobs'] }, { runWrangler: run, log: silentLog }, ); - expect(calls).toEqual([['queues', 'info', 'jobs']]); + expect(calls).toEqual([['queues', 'info', 'jobs', '-c', wranglerPath]]); expect(result.resources).toEqual([{ kind: 'queue', name: 'jobs', action: 'reused' }]); // Task 4 must NOT write queue producer/consumer entries into the config. expect(readConfig().queues).toBeUndefined(); @@ -986,10 +991,10 @@ describe('provisionCloudflareResources — Queues (resource only)', () => { setup(JSON.stringify({ name: 'app' })); const { run, calls } = makeRunner([ { - match: /^queues info emails$/, + match: /^queues info emails/, reply: () => fail('', 'X [ERROR] Queue "emails" does not exist.'), }, - { match: /^queues create emails$/, reply: () => ok('Created queue emails') }, + { match: /^queues create emails/, reply: () => ok('Created queue emails') }, ]); const result = await provisionCloudflareResources( tmpDir, @@ -998,8 +1003,8 @@ describe('provisionCloudflareResources — Queues (resource only)', () => { { runWrangler: run, log: silentLog }, ); expect(calls).toEqual([ - ['queues', 'info', 'emails'], - ['queues', 'create', 'emails'], + ['queues', 'info', 'emails', '-c', wranglerPath], + ['queues', 'create', 'emails', '-c', wranglerPath], ]); expect(result.resources).toEqual([{ kind: 'queue', name: 'emails', action: 'created' }]); expect(readConfig().queues).toBeUndefined(); @@ -1008,9 +1013,9 @@ describe('provisionCloudflareResources — Queues (resource only)', () => { it('handles a mix of existing and missing queues', async () => { setup(JSON.stringify({ name: 'app' })); const { run, calls } = makeRunner([ - { match: /^queues info jobs$/, reply: () => ok('Queue Name: jobs') }, - { match: /^queues info emails$/, reply: () => fail('', 'Queue "emails" does not exist.') }, - { match: /^queues create emails$/, reply: () => ok() }, + { match: /^queues info jobs/, reply: () => ok('Queue Name: jobs') }, + { match: /^queues info emails/, reply: () => fail('', 'Queue "emails" does not exist.') }, + { match: /^queues create emails/, reply: () => ok() }, ]); const result = await provisionCloudflareResources( tmpDir, @@ -1019,9 +1024,9 @@ describe('provisionCloudflareResources — Queues (resource only)', () => { { runWrangler: run, log: silentLog }, ); expect(calls).toEqual([ - ['queues', 'info', 'jobs'], - ['queues', 'info', 'emails'], - ['queues', 'create', 'emails'], + ['queues', 'info', 'jobs', '-c', wranglerPath], + ['queues', 'info', 'emails', '-c', wranglerPath], + ['queues', 'create', 'emails', '-c', wranglerPath], ]); expect(result.resources).toEqual([ { kind: 'queue', name: 'jobs', action: 'reused' }, @@ -1033,7 +1038,7 @@ describe('provisionCloudflareResources — Queues (resource only)', () => { setup(JSON.stringify({ name: 'app' })); const { run, calls } = makeRunner([ { - match: /^queues info jobs$/, + match: /^queues info jobs/, reply: () => fail('', 'X [ERROR] Authentication error [code: 10000]'), }, ]); @@ -1048,3 +1053,335 @@ describe('provisionCloudflareResources — Queues (resource only)', () => { expect(calls.some((c) => isCall(c, 'queues', 'create'))).toBe(false); }); }); + +describe('provisionCloudflareResources — account reconciliation + config pin (F1)', () => { + it('fails closed BEFORE any wrangler/REST call when config account_id differs from opts.accountId', async () => { + setup(JSON.stringify({ account_id: 'config-acct', name: 'app' })); + let restCalled = false; + const { run, calls } = makeRunner([{ match: /.*/, reply: () => ok() }]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, + { + runWrangler: run, + accountId: 'requested-acct', + log: silentLog, + listHyperdriveConfigs: async () => { + restCalled = true; + return []; + }, + }, + ), + ).rejects.toThrow(/account mismatch.*config-acct.*requested-acct/s); + // No wrangler call and no REST call happened before the fail-closed throw. + expect(calls).toEqual([]); + expect(restCalled).toBe(false); + }); + + it('passes -c to EVERY wrangler invocation (list, info, create)', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { match: /^d1 list --json/, reply: () => ok('[]') }, + { + match: /^d1 create /, + reply: () => { + simulateUpdateConfig('d1_databases', { + binding: 'DB', + database_name: 'myapp-db', + database_id: 'created-uuid', + }); + return ok(); + }, + }, + { match: /^kv namespace list/, reply: () => ok('[]') }, + { + match: /^kv namespace create /, + reply: () => { + simulateUpdateConfig('kv_namespaces', { binding: 'KV', id: 'new-kv-id' }); + return ok(); + }, + }, + { match: /^r2 bucket info /, reply: () => fail('', 'The specified bucket does not exist') }, + { + match: /^r2 bucket create /, + reply: () => { + simulateUpdateConfig('r2_buckets', { binding: 'STORAGE', bucket_name: 'b' }); + return ok(); + }, + }, + { match: /^queues info /, reply: () => fail('', 'Queue "jobs" does not exist.') }, + { match: /^queues create /, reply: () => ok() }, + ]); + await provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + d1: { binding: 'DB', name: 'myapp-db' }, + kvNamespaces: [{ binding: 'KV', title: 'myapp-kv' }], + r2: { binding: 'STORAGE', bucketName: 'b' }, + queues: ['jobs'], + }, + { runWrangler: run, log: silentLog }, + ); + // Every wrangler argv threads the ONE resolved config via `-c `. + expect(calls.length).toBeGreaterThan(0); + for (const argv of calls) { + const i = argv.indexOf('-c'); + expect(i).toBeGreaterThanOrEqual(0); + expect(argv[i + 1]).toBe(wranglerPath); + } + }); + + it('the default Hyperdrive existence REST call targets the effective (config) account id', async () => { + setup( + JSON.stringify({ + account_id: 'config-acct', + hyperdrive: [{ binding: 'HYPERDRIVE', id: 'local' }], + }), + ); + const urls: Array = []; + const origFetch = globalThis.fetch; + globalThis.fetch = (async (url: string | URL) => { + urls.push(String(url)); + return new Response( + JSON.stringify({ success: true, result: [], result_info: { total_count: 0 } }), + { status: 200 }, + ); + }) as typeof fetch; + try { + const { run } = makeRunner([]); + await provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + // Empty connection string → skip AFTER the existence check, so ONLY the + // REST call runs (no create). + hyperdrive: { binding: 'HYPERDRIVE', name: 'myapp-hd', connectionString: '' }, + }, + { runWrangler: run, apiToken: 'tok', log: silentLog }, + ); + } finally { + globalThis.fetch = origFetch; + } + expect(urls.length).toBeGreaterThan(0); + expect(urls[0]).toContain('/accounts/config-acct/hyperdrive/configs'); + }); +}); + +describe('provisionCloudflareResources — Hyperdrive discovery (F2 pagination + fail closed)', () => { + function stubFetch(pages: Array<{ status?: number; body: unknown }>): { + restore: () => void; + urls: Array; + } { + const urls: Array = []; + const origFetch = globalThis.fetch; + let call = 0; + globalThis.fetch = (async (url: string | URL) => { + urls.push(String(url)); + const page = pages[Math.min(call, pages.length - 1)]; + call++; + return new Response(typeof page.body === 'string' ? page.body : JSON.stringify(page.body), { + status: page.status ?? 200, + }); + }) as typeof fetch; + return { + restore: () => { + globalThis.fetch = origFetch; + }, + urls, + }; + } + + it('follows result_info pagination and adopts a match found on page 2 (no create)', async () => { + setup( + JSON.stringify({ account_id: 'acct', hyperdrive: [{ binding: 'HYPERDRIVE', id: 'local' }] }), + ); + const { restore, urls } = stubFetch([ + { + body: { + success: true, + result: [{ id: 'p1a', name: 'other-1' }], + result_info: { total_count: 2 }, + }, + }, + { + body: { + success: true, + result: [{ id: 'p2-hd', name: 'myapp-hd' }], + result_info: { total_count: 2 }, + }, + }, + ]); + try { + const { run, calls } = makeRunner([]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + hyperdrive: { binding: 'HYPERDRIVE', name: 'myapp-hd', connectionString: 'postgres://x' }, + }, + { runWrangler: run, apiToken: 'tok', log: silentLog }, + ); + expect(urls.length).toBe(2); // both pages fetched + expect(calls.some((c) => isCall(c, 'hyperdrive', 'create'))).toBe(false); + const hd = readConfig().hyperdrive as Array>; + expect(hd).toEqual([{ binding: 'HYPERDRIVE', id: 'p2-hd' }]); + expect(result.resources[0]).toMatchObject({ action: 'adopted', id: 'p2-hd' }); + } finally { + restore(); + } + }); + + it('fails closed on a non-2xx envelope (never treats it as absence, no create)', async () => { + setup(JSON.stringify({ account_id: 'acct', name: 'app' })); + const { restore } = stubFetch([{ status: 500, body: { success: false } }]); + try { + const { run, calls } = makeRunner([]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + hyperdrive: { + binding: 'HYPERDRIVE', + name: 'myapp-hd', + connectionString: 'postgres://x', + }, + }, + { runWrangler: run, apiToken: 'tok', log: silentLog }, + ), + ).rejects.toThrow(/cannot verify whether Hyperdrive config "myapp-hd"/); + expect(calls.some((c) => isCall(c, 'hyperdrive', 'create'))).toBe(false); + } finally { + restore(); + } + }); + + it('fails closed on success:false and on a malformed entry (missing id) — no create', async () => { + setup(JSON.stringify({ account_id: 'acct', name: 'app' })); + // success:false first, then a malformed entry — both must fail closed. + for (const body of [ + { success: false, result: [] }, + { success: true, result: [{ name: 'myapp-hd' }], result_info: { total_count: 1 } }, + ]) { + const { restore } = stubFetch([{ body }]); + try { + const { run, calls } = makeRunner([]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + hyperdrive: { + binding: 'HYPERDRIVE', + name: 'myapp-hd', + connectionString: 'postgres://x', + }, + }, + { runWrangler: run, apiToken: 'tok', log: silentLog }, + ), + ).rejects.toThrow(/cannot verify whether Hyperdrive config "myapp-hd"/); + expect(calls.some((c) => isCall(c, 'hyperdrive', 'create'))).toBe(false); + } finally { + restore(); + } + } + }); + + it('fails closed when two configs share the requested name (ambiguous) — no create', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + hyperdrive: { binding: 'HYPERDRIVE', name: 'myapp-hd', connectionString: 'postgres://x' }, + }, + { + runWrangler: run, + log: silentLog, + listHyperdriveConfigs: async () => [ + { id: 'hd-1', name: 'myapp-hd' }, + { id: 'hd-2', name: 'myapp-hd' }, + ], + }, + ), + ).rejects.toThrow(/cannot adopt Hyperdrive config "myapp-hd".*share that name/s); + expect(calls.some((c) => isCall(c, 'hyperdrive', 'create'))).toBe(false); + }); +}); + +describe('provisionCloudflareResources — KV/R2 fail closed on schema-valid ambiguity (F3)', () => { + it('KV: fails closed (no create) when the target title matches but the entry has no id', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { match: /^kv namespace list/, reply: () => ok(JSON.stringify([{ title: 'myapp-kv' }])) }, + ]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, kvNamespaces: [{ binding: 'KV', title: 'myapp-kv' }] }, + { runWrangler: run, log: silentLog }, + ), + ).rejects.toThrow(/namespace titled "myapp-kv" with no valid id/); + expect(calls.some((c) => isCall(c, 'kv', 'namespace', 'create'))).toBe(false); + }); + + it('R2: does NOT adopt when `r2 bucket info` exits 0 but the payload names a different bucket', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { + match: /^r2 bucket info /, + reply: () => ok(JSON.stringify({ name: 'a-different-bucket' })), + }, + ]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, r2: { binding: 'STORAGE', bucketName: 'myapp-uploads' } }, + { runWrangler: run, log: silentLog }, + ), + ).rejects.toThrow(/did not identify the requested bucket/); + // Neither adopted nor created — the config is untouched. + expect(calls.some((c) => isCall(c, 'r2', 'bucket', 'create'))).toBe(false); + expect(readConfig().r2_buckets).toBeUndefined(); + }); +}); + +describe('normalizeRunWranglerResult — signal / spawn-error handling (F5)', () => { + it('treats a SIGTERM-killed child (status null, signal set) as FAILURE, not success', () => { + const r = normalizeRunWranglerResult({ + stdout: '', + stderr: '', + status: null, + signal: 'SIGTERM', + }); + expect(r.status).not.toBe(0); + expect(r.stderr).toContain('SIGTERM'); + }); + + it('treats a spawn error as FAILURE and surfaces its message', () => { + const r = normalizeRunWranglerResult({ + status: null, + error: new Error('spawn wrangler ENOENT'), + }); + expect(r.status).not.toBe(0); + expect(r.stderr).toContain('ENOENT'); + }); + + it('passes a clean exit through unchanged (0 stays success, a real non-zero is preserved)', () => { + expect(normalizeRunWranglerResult({ status: 0, stdout: 'ok' }).status).toBe(0); + expect(normalizeRunWranglerResult({ status: 2, stderr: 'boom' }).status).toBe(2); + }); +}); diff --git a/packages/void/test/unit/wrangler.test.ts b/packages/void/test/unit/wrangler.test.ts index d7205faab..b975d8f02 100644 --- a/packages/void/test/unit/wrangler.test.ts +++ b/packages/void/test/unit/wrangler.test.ts @@ -2244,4 +2244,83 @@ describe('writeProvisionedWranglerConfig', () => { // The .jsonc (which discovery would have preferred) is untouched. expect((parseRaw('wrangler.jsonc') as Record).account_id).toBeUndefined(); }); + + // Adopt collision guards must be built from the CURRENT text, cover MATCHED + // (existing-target) entries, include queues.producers, and reject duplicate D1 + // migration targets (F6) — not just the append/initial-parse cases. + + it('adopt fails closed when the binding collides with an existing queue PRODUCER binding — no write', () => { + // An existing queue producer already binds `DB`; adopting a D1 as `DB` would + // emit a cross-type duplicate binding name wrangler rejects at deploy. The base + // collector omits producers, so the adopt guard must layer them back in. + setup( + 'wrangler.jsonc', + `{ + "queues": { "producers": [{ "queue": "jobs", "binding": "DB" }] } +}`, + ); + const before = readRaw('wrangler.jsonc'); + expect(() => + writeProvisionedWranglerConfig(tmpDir, { + d1: { binding: 'DB', databaseName: 'app', databaseId: 'real-uuid' }, + }), + ).toThrow(/cannot adopt "DB" into "d1_databases".*already used by a Queue binding/s); + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('adopt fails closed when TWO adopts in one patch request the same binding (current-text rebuild, not stale map)', () => { + // A single patch adopting the same binding into two arrays would, with a map + // built once from the INITIAL parse, append both. The guard rebuilds from the + // current text before each adopt, so the second sees the first and fails closed. + setup('wrangler.jsonc', '{ "name": "app" }'); + const before = readRaw('wrangler.jsonc'); + expect(() => + writeProvisionedWranglerConfig(tmpDir, { + d1: { binding: 'DUP', databaseName: 'app', databaseId: 'real-uuid' }, + kv: { binding: 'DUP', id: 'real-kv-id' }, + }), + ).toThrow(/cannot adopt "DUP" into "kv_namespaces".*already used by a D1 Database binding/s); + // Fail closed — nothing written (the d1 append is discarded because the whole + // write only commits past the final gate). + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('adopt fails closed on a MATCHED existing-target entry whose binding also collides cross-type — no write', () => { + // The target D1 entry already exists (matched, not appended) AND a KV binds the + // same name — an undeployable cross-type duplicate. The MATCHED/update path must + // fail closed too, not just the append path. + setup( + 'wrangler.jsonc', + `{ + "d1_databases": [{ "binding": "DB", "database_name": "app", "database_id": "local" }], + "kv_namespaces": [{ "binding": "DB", "id": "abc" }] +}`, + ); + const before = readRaw('wrangler.jsonc'); + expect(() => + writeProvisionedWranglerConfig(tmpDir, { + d1: { binding: 'DB', databaseName: 'app', databaseId: 'real-uuid' }, + }), + ).toThrow(/cannot adopt "DB" into "d1_databases".*already used by a KV Namespace binding/s); + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('migrations_dir fails closed when the D1 binding is declared more than once (no findIndex-first)', () => { + setup( + 'wrangler.jsonc', + `{ + "d1_databases": [ + { "binding": "DB", "database_name": "a", "database_id": "id-a" }, + { "binding": "DB", "database_name": "b", "database_id": "id-b" } + ] +}`, + ); + const before = readRaw('wrangler.jsonc'); + expect(() => + writeProvisionedWranglerConfig(tmpDir, { + d1MigrationsDir: { binding: 'DB', dir: './db/migrations' }, + }), + ).toThrow(/binding "DB" is declared 2 times in "d1_databases"/); + expect(readRaw('wrangler.jsonc')).toBe(before); + }); }); From 64a942afa3c60d9587a94378329f5240ec2fe5c0 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 16 Jul 2026 15:17:16 +0800 Subject: [PATCH 019/128] fix(provision): unify fail-closed discovery + close 6 codex round-3 findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route every Cloudflare resource path through three shared decision points so the same bug class cannot survive in an un-touched sibling path: - assertDiscoverySucceeded — fail closed unless numeric status 0, before parse (D1 list, D1 info, KV list) - validateResourceList — reject the whole list on any malformed member, never filter (KV, D1) - isConfirmedAbsent — "absent -> create" only on the exact, name-aware not-found signal (R2 10006/message, queue `Queue "" does not exist`) Findings: - F1: persist account_id BEFORE any remote op so a partial run cannot bind account-A ids under account B (a later different-account retry now fails closed) - F2: prove Hyperdrive pagination completeness (distinct-id tracking + consistent total); fail closed on missing/empty/duplicate/inconsistent pages - F3: KV list rejects a malformed member instead of filtering it to false "absent" - F4: D1 list/info gated on success + strict-validated + identity-matched - F5: queue not-found is exact + name-aware (no more "Account not found" -> create) - F6: launcher prefers void's own wrangler; app-root only as fallback Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/cli/preset.ts | 35 ++- packages/void/src/cli/provision.ts | 349 ++++++++++++++++------ packages/void/test/unit/preset.test.ts | 32 +- packages/void/test/unit/provision.test.ts | 333 +++++++++++++++++++++ 4 files changed, 638 insertions(+), 111 deletions(-) diff --git a/packages/void/src/cli/preset.ts b/packages/void/src/cli/preset.ts index 39f59e1df..12cee8e2b 100644 --- a/packages/void/src/cli/preset.ts +++ b/packages/void/src/cli/preset.ts @@ -206,12 +206,15 @@ export function resolveWranglerCommand(root: string, subcommand: string): string * `[process.execPath, ]`, which is shell-free AND * cross-platform (it sidesteps the Windows `.cmd`/`.ps1` shim that Node cannot * spawn with `shell: false`): - * 1. The app-root `node_modules/wrangler` probe (a consumer may hoist its own - * wrangler at the project root). - * 2. MODULE RESOLUTION of `wrangler/package.json`. wrangler is a dependency of - * the `void` PACKAGE, so under strict pnpm (void + consumers use pnpm) it is - * NOT at the app root — it lives under void's own deps. Resolving relative to - * THIS module finds it wherever pnpm placed it. + * 1. MODULE RESOLUTION of `wrangler/package.json` relative to THIS module (the + * `void` package). The provisioner targets wrangler 4.107's EXACT flags, + * `--json` shapes, and error strings, so void's own pinned wrangler must be + * PREFERRED — an app that hoists an older, incompatible wrangler at its root + * must not shadow it. Under strict pnpm (void + consumers use pnpm) void's + * wrangler is not at the app root anyway; module resolution finds it wherever + * pnpm placed it. + * 2. FALLBACK: the app-root `node_modules/wrangler` probe, used only when void's + * own wrangler cannot be resolved (e.g. a bundled/vendored layout). * * If wrangler cannot be resolved by EITHER avenue, throws a clear error — it never * degrades to a bare `wrangler` (ENOENT in clean CI) or a `.cmd` shim. @@ -254,20 +257,24 @@ function defaultResolveWranglerPackageJson(): string | undefined { /** * Absolute path to the wrangler JS entry (from its `package.json` `bin.wrangler`), - * trying the app-root probe first, then the injected module resolver. Returns - * `undefined` when wrangler cannot be located or its manifest is unreadable. + * trying the injected module resolver (void's OWN wrangler) FIRST, then falling + * back to the app-root probe. Returns `undefined` when wrangler cannot be located + * or its manifest is unreadable. */ function resolveLocalWranglerJsBin( root: string, resolveWranglerPackageJson: () => string | undefined, ): string | undefined { - const fromRoot = wranglerJsBinFromPackageJson( - join(root, 'node_modules', 'wrangler', 'package.json'), - ); - if (fromRoot) { - return fromRoot; + // Prefer void's own wrangler (module resolution relative to preset.ts): the + // provisioner targets its exact 4.107 flags/JSON/errors, so an older app-root + // wrangler must not win over it. + const fromVoid = wranglerJsBinFromPackageJson(resolveWranglerPackageJson()); + if (fromVoid) { + return fromVoid; } - return wranglerJsBinFromPackageJson(resolveWranglerPackageJson()); + // Fallback: a consumer-hoisted wrangler at the project root, only when void's + // own resolution failed. + return wranglerJsBinFromPackageJson(join(root, 'node_modules', 'wrangler', 'package.json')); } /** diff --git a/packages/void/src/cli/provision.ts b/packages/void/src/cli/provision.ts index 13118e984..2ab0a36bb 100644 --- a/packages/void/src/cli/provision.ts +++ b/packages/void/src/cli/provision.ts @@ -213,8 +213,17 @@ function createDefaultListHyperdriveConfigs( // ~25 configs, paginated) and FAIL CLOSED on any non-2xx, `success !== true`, // or a malformed entry. Never coerce a malformed envelope/entry to "absence": // that would mint a duplicate config on a re-run. + // + // Completeness is PROVEN, not assumed: we require consistent pagination + // metadata (`result_info.total_count`) and keep going until the DISTINCT + // config count equals it. Any of — missing metadata, an empty page before all + // configs are seen, a repeated/duplicate id, an inconsistent total, or an + // overshoot — throws (fail closed) rather than silently declaring the list + // complete (which could miss a same-named config on an unread page and mint a + // duplicate). const perPage = 100; - const all: Array = []; + const byId = new Map(); + let expectedTotal: number | undefined; for (let page = 1; ; page++) { const res = await fetch( `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/hyperdrive/configs?page=${page}&per_page=${perPage}`, @@ -229,6 +238,34 @@ function createDefaultListHyperdriveConfigs( 'Cloudflare API returned an unsuccessful or malformed Hyperdrive configs payload', ); } + // Pagination metadata is REQUIRED to prove completeness. Its absence means + // we cannot know whether more pages exist → fail closed. + const info = body.result_info; + const totalCount = + isRecord(info) && typeof info.total_count === 'number' && Number.isInteger(info.total_count) + ? info.total_count + : undefined; + if (totalCount === undefined || totalCount < 0) { + throw new Error( + 'Cloudflare API returned Hyperdrive configs without valid pagination metadata ' + + '(result_info.total_count); cannot prove the list is complete', + ); + } + if (expectedTotal === undefined) { + expectedTotal = totalCount; + } else if (totalCount !== expectedTotal) { + throw new Error( + 'Cloudflare API returned inconsistent Hyperdrive pagination totals across pages; ' + + 'cannot prove the list is complete', + ); + } + // An empty page before we have gathered every config is premature truncation. + if (body.result.length === 0 && byId.size < expectedTotal) { + throw new Error( + 'Cloudflare API returned an empty Hyperdrive page before all configs were listed; ' + + 'cannot prove the list is complete', + ); + } for (const entry of body.result) { if ( !isRecord(entry) || @@ -241,38 +278,43 @@ function createDefaultListHyperdriveConfigs( 'Cloudflare API returned a malformed Hyperdrive config entry (missing id/name)', ); } - all.push({ id: entry.id, name: entry.name }); + if (byId.has(entry.id)) { + throw new Error( + 'Cloudflare API returned a duplicate Hyperdrive config id across pages; ' + + 'cannot prove the list is complete', + ); + } + byId.set(entry.id, { id: entry.id, name: entry.name }); } - // Follow `result_info` pagination: stop once we have gathered `total_count` - // entries (or the page came back empty / without pagination metadata). - const info = body.result_info; - const totalCount = - isRecord(info) && typeof info.total_count === 'number' ? info.total_count : undefined; - if (totalCount === undefined || body.result.length === 0 || all.length >= totalCount) { + if (byId.size > expectedTotal) { + throw new Error( + 'Cloudflare API returned more Hyperdrive configs than result_info.total_count; ' + + 'cannot prove the list is complete', + ); + } + // Done ONLY when the distinct count matches the proven total. Each remaining + // iteration adds ≥1 distinct id (a duplicate or empty page threw above), so + // this loop is bounded by `expectedTotal`. + if (byId.size === expectedTotal) { break; } } - return all; + return [...byId.values()]; }; } -/** Parse a wrangler `--json` stdout into an array, tolerating noise/failure. */ -function parseJsonArray(stdout: string): Array> { - const value = parseJsonLoose(stdout); - return Array.isArray(value) ? (value.filter(isRecord) as Array>) : []; -} - /** * Parse a wrangler `--json` stdout into an array, distinguishing a VALID JSON * array (returns it, possibly empty) from unparseable/non-array output (returns - * `undefined`). Used where an empty list must mean "authoritatively absent" and - * garbage must NOT be silently coerced to "absent" (the KV pre-check). + * `undefined`). Members are returned UNFILTERED — a malformed member (e.g. a + * `null`) is preserved, NOT silently dropped, so {@link validateResourceList} + * can fail closed on it (a dropped malformed member could BE the target and look + * like "absent", minting a duplicate). Used everywhere an empty list must mean + * "authoritatively absent" and garbage must NOT be coerced to "absent". */ -function parseJsonArrayStrict(stdout: string): Array> | undefined { +function parseJsonArrayStrict(stdout: string): Array | undefined { const value = parseJsonLoose(stdout); - return Array.isArray(value) - ? (value.filter(isRecord) as Array>) - : undefined; + return Array.isArray(value) ? value : undefined; } /** Parse a wrangler `--json` stdout into an object, tolerating noise/failure. */ @@ -339,7 +381,98 @@ function readRootConfig(configPath: string): Record { return parsed; } -// --- wrangler output extractors (one place to align field names with wrangler) --- +// --- Shared fail-closed discovery gates (one place per class of check) -------- +// The three helpers below are the SINGLE decision points every resource path +// routes through, so no sibling path can drift into a per-path bug again: +// 1. assertDiscoverySucceeded — a discovery/list/info result is only trusted +// when it exited cleanly (numeric status 0). Called BEFORE any stdout parse. +// 2. validateResourceList — a `--json` list is rejected WHOLE if ANY member +// is malformed (never silently filtered — a malformed member could BE the +// target and look "absent", minting a duplicate). +// 3. isConfirmedAbsent — a NON-zero ` info`-by-name probe means +// "absent" (→ create) ONLY on wrangler's EXACT, name-aware not-found signal; +// a signal/spawn failure or any other nonzero is NEVER absence. + +/** Escape a string for literal embedding in a `RegExp`. */ +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * FAIL-CLOSED gate over a discovery/list/info result. Throws (no create) unless + * the child exited cleanly — a NUMERIC `status === 0`. Because + * {@link normalizeRunWranglerResult} already folds a signal-killed child or a + * spawn error into a non-zero status, a single numeric check here rejects ALL of + * nonzero-exit / SIGTERM / spawn-error / non-numeric status. MUST be called + * BEFORE parsing stdout, so a failed result can never be parsed as "absent". + */ +function assertDiscoverySucceeded(result: RunWranglerResult, label: string): void { + if (typeof result.status !== 'number' || result.status !== 0) { + throw new Error( + `provision: "${label}" failed (exit ${String(result.status)}): ` + + `${result.stderr || result.stdout || 'no output'}. Cannot verify existing resources; ` + + `refusing to create a possible duplicate.`, + ); + } +} + +/** Field spec describing a wrangler `--json` list member's required identity. */ +interface ResourceListSpec { + /** Human resource name for messages, e.g. `KV namespace` / `D1 database`. */ + resource: string; + /** Command name for messages, e.g. `wrangler kv namespace list`. */ + command: string; + /** Any ONE of these holding a non-empty string satisfies "has an id". */ + idFields: Array; + /** The member field carrying the account-matched name/title. */ + nameField: string; + /** Grammatical label for the name field in messages: `titled` / `named`. */ + nameLabel: string; +} + +/** + * STRICT list validator shared by KV + D1. Rejects the ENTIRE list (throws) if + * ANY member is not a well-formed object carrying the required non-empty string + * fields — a member is NEVER silently filtered, because a malformed member could + * BE the requested target and dropping it would look like "absent" and mint a + * duplicate. Returns the validated members (every one guaranteed to have a valid + * id + name) on success. + */ +function validateResourceList( + list: Array, + spec: ResourceListSpec, +): Array> { + const validated: Array> = []; + for (const member of list) { + if (!isRecord(member)) { + throw new Error( + `provision: "${spec.command}" returned a malformed ${spec.resource} entry (not an object). ` + + `Cannot verify existing resources; refusing to create a possible duplicate.`, + ); + } + const rawName = member[spec.nameField]; + const nameValid = typeof rawName === 'string' && rawName.length > 0; + const idValid = spec.idFields.some((f) => { + const value = member[f]; + return typeof value === 'string' && value.length > 0; + }); + if (nameValid && !idValid) { + throw new Error( + `provision: "${spec.command}" returned a ${spec.resource} ${spec.nameLabel} ` + + `${JSON.stringify(rawName)} with no valid id. Cannot verify whether it already exists; ` + + `refusing to create a possible duplicate.`, + ); + } + if (!nameValid) { + throw new Error( + `provision: "${spec.command}" returned a malformed ${spec.resource} entry with no valid ` + + `${spec.nameField}. Cannot verify existing resources; refusing to create a possible duplicate.`, + ); + } + validated.push(member); + } + return validated; +} /** `wrangler d1 list --json` / `d1 info --json` → `{ id, name }`. */ function extractD1( @@ -352,16 +485,14 @@ function extractD1( if (typeof id !== 'string' || id.length === 0) { return undefined; } - return { id, name: typeof entry.name === 'string' ? entry.name : undefined }; + const name = entry.name ?? entry.database_name; + return { id, name: typeof name === 'string' ? name : undefined }; } /** * `wrangler kv namespace list` → the `{ id }` of every namespace whose title - * matches. FAILS CLOSED: a namespace whose title matches the target but carries - * no non-empty string `id` (a schema-valid-but-idless member like - * `{ "title": "target" }`) is NOT silently dropped — dropping it would look like - * "absent" and mint a DUPLICATE namespace (KV titles are not account-unique). It - * throws so the caller surfaces the ambiguity instead of blindly creating. + * matches. The list has ALREADY been through {@link validateResourceList} (every + * member has a non-empty string `id` + `title`), so this only filters + collects. */ function extractKvMatches( list: Array>, @@ -373,14 +504,9 @@ function extractKvMatches( continue; } const id = ns.id; - if (typeof id !== 'string' || id.length === 0) { - throw new Error( - `provision: "wrangler kv namespace list" returned a namespace titled ${JSON.stringify(title)} ` + - `with no valid id. Cannot verify whether it already exists; refusing to create a possible ` + - `duplicate (KV titles are not account-unique). Inspect the account and retry.`, - ); + if (typeof id === 'string' && id.length > 0) { + matches.push({ id }); } - matches.push({ id }); } return matches; } @@ -392,31 +518,30 @@ function isAlreadyExistsError(result: RunWranglerResult): boolean { } /** - * True when a failing ` info/get` means the resource simply does NOT exist - * (safe to create), vs. a different failure (auth/network) we must surface - * instead of blindly creating. wrangler's `getQueue` throws `Queue "x" does not - * exist`; the R2/other get-by-name paths throw comparable not-found errors. - */ -function isMissingResourceError(result: RunWranglerResult): boolean { - const text = `${result.stdout}\n${result.stderr}`; - return /does not exist|not found|could not be found|no such/i.test(text); -} - -/** - * True ONLY when a failing `r2 bucket info` is the confirmed bucket-not-found - * signal. The DEFINITIVE signal is CF API error code `10006` — the exact code - * wrangler's own `isConnectedToExistingResource` treats as "bucket absent" — plus - * wrangler's exact R2 not-found message. In wrangler 4.107 `r2 bucket info` does - * the bucket lookup AND a separate metrics/GraphQL query, so an EXISTING bucket - * can exit non-zero on a metrics/auth/network failure. We deliberately do NOT - * fall back to the broad `isMissingResourceError` regex (`not found`/`no such`/…): - * an unrelated metrics/GraphQL error whose text loosely contains "not found" - * would be misread as absence and mint a duplicate. Create ONLY on this confirmed - * signal; any other non-zero is surfaced. + * EXACT, name-aware not-found classifier shared by R2 + queues. Given a + * already-NON-zero ` info`-by-name probe, returns `true` (confirmed absent → + * safe to create) ONLY on wrangler's exact, resource-specific not-found signal + * for THIS name (regex-escaped). Everything else returns `false` so the caller + * fails closed — a signal/spawn failure, an auth/network/metrics error, or a + * GENERIC "not found" (e.g. `Account not found`) is NEVER treated as absence, + * which would mint a duplicate resource. */ -function isBucketNotFoundError(result: RunWranglerResult): boolean { +function isConfirmedAbsent(result: RunWranglerResult, kind: 'r2' | 'queue', name: string): boolean { const text = `${result.stdout}\n${result.stderr}`; - return /\b10006\b/.test(text) || /the specified bucket does not exist/i.test(text); + switch (kind) { + case 'r2': + // CF API error code 10006 (the exact code wrangler's own + // `isConnectedToExistingResource` treats as "bucket absent") or wrangler's + // exact R2 not-found message. R2's message does not embed the bucket name, + // so the code/message IS the exact signal. A metrics/auth/network failure + // (whose text may loosely contain "not found") is NOT matched here. + return /\b10006\b/.test(text) || /the specified bucket does not exist/i.test(text); + case 'queue': + // wrangler's `getQueue` throws exactly `Queue "" does not exist`. + // Match ONLY that message for THIS queue name — a generic `not found` / + // `no such` / `Account not found` must NOT be read as queue absence. + return new RegExp(`Queue\\s+"${escapeRegExp(name)}"\\s+does not exist`, 'i').test(text); + } } /** @@ -500,6 +625,22 @@ export async function provisionCloudflareResources( patch: Parameters[1], ): WriteProvisionedWranglerResult => writeProvisionedWranglerConfig(root, patch, { configPath }); + // --- account_id (PERSIST FIRST, before any remote side effect) ----------- + // Pin the effective account_id NOW — before any create writes a real id — so a + // partial run can never leave account-A's freshly-minted ids in a config with + // NO account pin. Without this, a later retry under account B would see the real + // id (read-first hit → skips discovery) and finally stamp `account_id = B`, + // binding account-A resources under account B. With the pin written up front, a + // different-account retry instead hits the reconciliation above (config account + // ≠ requested → fail closed) before touching wrangler/REST. `effectiveAccountId` + // equals `opts.accountId` whenever the latter is set (a config/opts mismatch has + // already thrown), so this writes exactly the requested account. + let accountIdWritten = false; + if (opts?.accountId) { + write({ accountId: opts.accountId }); + accountIdWritten = true; + } + // --- D1 ------------------------------------------------------------------ if (desired.d1) { const { binding, name } = desired.d1; @@ -513,11 +654,28 @@ export async function provisionCloudflareResources( assertD1MigrationsDirApplied(res, binding, configPath); resources.push({ kind: 'd1', binding, name, action: 'reused', id: entry.database_id }); } else { - const existing = extractD1( - parseJsonArray( - runWrangler(['d1', 'list', '--json', '-c', configPath], { captureJson: true }).stdout, - ).find((db) => db.name === name), - ); + // Discovery must SUCCEED (gate) before its stdout is parsed, then the whole + // list is strictly validated — a nonzero/SIGTERM/spawn-error or a malformed + // member never parses as "absent" and mints a duplicate D1. + const listResult = runWrangler(['d1', 'list', '--json', '-c', configPath], { + captureJson: true, + }); + assertDiscoverySucceeded(listResult, 'wrangler d1 list'); + const rawList = parseJsonArrayStrict(listResult.stdout); + if (!rawList) { + throw new Error( + `provision: "wrangler d1 list --json" did not return a JSON array; cannot verify whether ` + + `D1 database ${JSON.stringify(name)} exists. Refusing to create a possible duplicate.`, + ); + } + const databases = validateResourceList(rawList, { + resource: 'D1 database', + command: 'wrangler d1 list', + idFields: ['uuid', 'database_id'], + nameField: 'name', + nameLabel: 'named', + }); + const existing = extractD1(databases.find((db) => db.name === name)); if (existing) { log.info(`adopted existing D1 database ${name} (${existing.id})`); const res = write({ @@ -540,17 +698,17 @@ export async function provisionCloudflareResources( if (created.status !== 0) { if (isAlreadyExistsError(created)) { // TOCTOU: created between our list and create (code 7502). Capture - // the id via `d1 info` and adopt instead of failing. - const info = extractD1( - parseJsonObject( - runWrangler(['d1', 'info', name, '--json', '-c', configPath], { captureJson: true }) - .stdout, - ), - ); - if (!info) { + // the id via `d1 info` — which must SUCCEED (gate) and POSITIVELY + // identify THIS database (name match) before we adopt its id. + const infoResult = runWrangler(['d1', 'info', name, '--json', '-c', configPath], { + captureJson: true, + }); + assertDiscoverySucceeded(infoResult, `wrangler d1 info ${name}`); + const info = extractD1(parseJsonObject(infoResult.stdout)); + if (!info || (info.name !== undefined && info.name !== name)) { throw new Error( `provision: "wrangler d1 create ${name}" reported the database already exists, ` + - `but its id could not be resolved via "wrangler d1 info ${name} --json".`, + `but "wrangler d1 info ${name} --json" did not return a matching database id/name.`, ); } log.info(`adopted existing D1 database ${name} (${info.id})`); @@ -584,28 +742,30 @@ export async function provisionCloudflareResources( continue; } // KV titles are NOT account-unique and `kv namespace create` never dedupes, - // so this list is the ONLY orphan guard. Require status 0 AND a schema-valid - // JSON array before treating an absent title as authoritative — otherwise a - // nonzero/auth/network/truncated result would parse as "absent" and mint a - // duplicate namespace. Fail closed (throw) instead. + // so this list is the ONLY orphan guard. Route it through the SHARED gate + + // strict validator: the discovery must exit 0 (nonzero/SIGTERM/spawn-error → + // throw), return a real JSON array, and have EVERY member well-formed (a + // malformed member — which could BE the target — fails closed rather than being + // filtered to a false "absent" that mints a duplicate namespace). const listResult = runWrangler(['kv', 'namespace', 'list', '-c', configPath], { captureJson: true, }); - if (listResult.status !== 0) { - throw new Error( - `provision: "wrangler kv namespace list" failed: ${listResult.stderr || listResult.stdout}. ` + - `Cannot verify whether KV namespace ${JSON.stringify(title)} exists; refusing to create a ` + - `possible duplicate (KV titles are not account-unique).`, - ); - } - const parsedList = parseJsonArrayStrict(listResult.stdout); - if (!parsedList) { + assertDiscoverySucceeded(listResult, 'wrangler kv namespace list'); + const rawList = parseJsonArrayStrict(listResult.stdout); + if (!rawList) { throw new Error( `provision: "wrangler kv namespace list" did not return a JSON array; cannot verify whether ` + `KV namespace ${JSON.stringify(title)} exists. Refusing to create a possible duplicate.`, ); } - const matches = extractKvMatches(parsedList, title); + const namespaces = validateResourceList(rawList, { + resource: 'KV namespace', + command: 'wrangler kv namespace list', + idFields: ['id'], + nameField: 'title', + nameLabel: 'titled', + }); + const matches = extractKvMatches(namespaces, title); if (matches.length > 0) { // KV titles are NOT account-unique. Tie-break deterministically on the // lexicographically-smallest id so re-runs adopt the same namespace. @@ -669,7 +829,7 @@ export async function provisionCloudflareResources( log.info(`adopted existing R2 bucket ${bucketName}`); write({ r2: { binding, bucketName } }); resources.push({ kind: 'r2', binding, name: bucketName, action: 'adopted' }); - } else if (isBucketNotFoundError(info)) { + } else if (isConfirmedAbsent(info, 'r2', bucketName)) { // CONFIRMED not-found → create. `--update-config` upserts by binding. const created = runWrangler([ 'r2', @@ -801,7 +961,11 @@ export async function provisionCloudflareResources( resources.push({ kind: 'queue', name, action: 'reused' }); continue; } - if (!isMissingResourceError(info)) { + // A non-zero probe is "absent" (→ create) ONLY on the EXACT, name-aware + // not-found signal (`Queue "" does not exist`). Any other nonzero — a + // signal/spawn failure, an auth error, or a generic `Account not found` — is + // NOT absence and fails closed (never a blind create). + if (!isConfirmedAbsent(info, 'queue', name)) { throw new Error( `provision: "wrangler queues info ${name}" failed: ${info.stderr || info.stdout}`, ); @@ -815,12 +979,7 @@ export async function provisionCloudflareResources( resources.push({ kind: 'queue', name, action: 'created' }); } - // --- account_id ---------------------------------------------------------- - let accountIdWritten = false; - if (opts?.accountId) { - write({ accountId: opts.accountId }); - accountIdWritten = true; - } - + // account_id was persisted up front (before any remote side effect) — see the + // account_id block near the top of this function (F1 fail-closed pin). return { resources, configPath, accountIdWritten }; } diff --git a/packages/void/test/unit/preset.test.ts b/packages/void/test/unit/preset.test.ts index 3e60688fc..5590760c0 100644 --- a/packages/void/test/unit/preset.test.ts +++ b/packages/void/test/unit/preset.test.ts @@ -292,7 +292,34 @@ describe('resolveWranglerArgv (module-resolution launcher)', () => { expect(argv[1].replace(/\\/g, '/')).toMatch(/wrangler\/bin\/wrangler\.js$/); }); - it('prefers the app-root wrangler package.json when present, launched via [execPath, jsBin]', () => { + it("prefers void's own wrangler over an incompatible app-root wrangler (F6)", () => { + // The app root pins its OWN (potentially older/incompatible) wrangler, and a + // separate "void-nested" wrangler is resolvable via module resolution. The + // provisioner targets wrangler 4.107's exact flags/JSON/errors, so void's own + // wrangler MUST win — the app-root one must not shadow it. + const root = setupProject({ + pkg: { dependencies: { wrangler: '^3.0.0' } }, + files: { + 'node_modules/wrangler/package.json': JSON.stringify({ + bin: { wrangler: './bin/wrangler.js' }, + }), + 'node_modules/wrangler/bin/wrangler.js': '#!/usr/bin/env node\n', + 'void_modules/wrangler/package.json': JSON.stringify({ + bin: { wrangler: './bin/wrangler.js' }, + }), + 'void_modules/wrangler/bin/wrangler.js': '#!/usr/bin/env node\n', + }, + }); + const voidWranglerPkg = join(root, 'void_modules', 'wrangler', 'package.json'); + const argv = resolveWranglerArgv(root, () => voidWranglerPkg); + // Resolved to void's nested wrangler, NOT the app-root one. + expect(argv).toEqual([ + process.execPath, + join(root, 'void_modules', 'wrangler', 'bin', 'wrangler.js'), + ]); + }); + + it('falls back to the app-root wrangler only when void resolution fails (F6)', () => { const root = setupProject({ pkg: { dependencies: { wrangler: '^4.0.0' } }, files: { @@ -302,7 +329,8 @@ describe('resolveWranglerArgv (module-resolution launcher)', () => { 'node_modules/wrangler/bin/wrangler.js': '#!/usr/bin/env node\n', }, }); - const argv = resolveWranglerArgv(root); + // void's own resolution returns undefined → fall back to the app-root probe. + const argv = resolveWranglerArgv(root, () => undefined); expect(argv).toEqual([ process.execPath, join(root, 'node_modules', 'wrangler', 'bin', 'wrangler.js'), diff --git a/packages/void/test/unit/provision.test.ts b/packages/void/test/unit/provision.test.ts index 8815bcced..a8ff7e0ad 100644 --- a/packages/void/test/unit/provision.test.ts +++ b/packages/void/test/unit/provision.test.ts @@ -451,6 +451,115 @@ describe('provisionCloudflareResources — D1', () => { }); }); +describe('provisionCloudflareResources — D1 discovery gate + strict validation (F4)', () => { + const d1Desired: DesiredResourceSet = { + ...emptyDesired, + d1: { binding: 'DB', name: 'myapp-db' }, + }; + + it('fails closed (no create) when `d1 list` exits non-zero', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { + match: /^d1 list --json/, + reply: () => fail('', 'X [ERROR] Authentication error [code: 10000]'), + }, + ]); + await expect( + provisionCloudflareResources(tmpDir, wranglerPath, d1Desired, { + runWrangler: run, + log: silentLog, + }), + ).rejects.toThrow(/d1 list.*failed/s); + expect(calls.some((c) => isCall(c, 'd1', 'create'))).toBe(false); + expect(readConfig().d1_databases).toBeUndefined(); + }); + + it('fails closed (no create) on a SIGTERM-killed `d1 list` (normalized to non-zero)', async () => { + setup(JSON.stringify({ name: 'app' })); + const killed = normalizeRunWranglerResult({ + stdout: '', + stderr: '', + status: null, + signal: 'SIGTERM', + }); + const { run, calls } = makeRunner([{ match: /^d1 list --json/, reply: () => killed }]); + await expect( + provisionCloudflareResources(tmpDir, wranglerPath, d1Desired, { + runWrangler: run, + log: silentLog, + }), + ).rejects.toThrow(/d1 list.*failed/s); + expect(calls.some((c) => isCall(c, 'd1', 'create'))).toBe(false); + }); + + it('fails closed (no create) on a spawn-error `d1 list` (normalized to non-zero)', async () => { + setup(JSON.stringify({ name: 'app' })); + const errored = normalizeRunWranglerResult({ + status: null, + error: new Error('spawn wrangler ENOENT'), + }); + const { run, calls } = makeRunner([{ match: /^d1 list --json/, reply: () => errored }]); + await expect( + provisionCloudflareResources(tmpDir, wranglerPath, d1Desired, { + runWrangler: run, + log: silentLog, + }), + ).rejects.toThrow(/d1 list.*failed/s); + expect(calls.some((c) => isCall(c, 'd1', 'create'))).toBe(false); + }); + + it('fails closed (no create) when `d1 list` returns truncated JSON (status 0)', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { match: /^d1 list --json/, reply: () => ok('[{ "uuid": "a", "name": "myapp-db"') }, + ]); + await expect( + provisionCloudflareResources(tmpDir, wranglerPath, d1Desired, { + runWrangler: run, + log: silentLog, + }), + ).rejects.toThrow(/did not return a JSON array/); + expect(calls.some((c) => isCall(c, 'd1', 'create'))).toBe(false); + }); + + it('fails closed (no create) when a `d1 list` member is malformed — even when it IS the target name', async () => { + // The target-named member carries NO id → it must NOT be filtered to a false + // "absent" that mints a duplicate D1. + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { match: /^d1 list --json/, reply: () => ok(JSON.stringify([{ name: 'myapp-db' }])) }, + ]); + await expect( + provisionCloudflareResources(tmpDir, wranglerPath, d1Desired, { + runWrangler: run, + log: silentLog, + }), + ).rejects.toThrow(/D1 database named "myapp-db" with no valid id/); + expect(calls.some((c) => isCall(c, 'd1', 'create'))).toBe(false); + }); + + it('fails closed (no adopt/write) when the collision `d1 info` exits non-zero', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run } = makeRunner([ + { match: /^d1 list --json/, reply: () => ok('[]') }, + { match: /^d1 create /, reply: () => fail('', 'X [ERROR] already exists [code: 7502]') }, + { + match: /^d1 info myapp-db --json/, + reply: () => fail('', 'X [ERROR] Authentication error [code: 10000]'), + }, + ]); + await expect( + provisionCloudflareResources(tmpDir, wranglerPath, d1Desired, { + runWrangler: run, + log: silentLog, + }), + ).rejects.toThrow(/d1 info myapp-db.*failed/s); + // The 7502 create wrote nothing, and the failed info produced no adopt/write. + expect(readConfig().d1_databases).toBeUndefined(); + }); +}); + describe('provisionCloudflareResources — KV', () => { it('adopts when the namespace list shows a title match (writes id, no create)', async () => { setup(JSON.stringify({ kv_namespaces: [{ binding: 'KV', id: 'local' }] })); @@ -1052,6 +1161,68 @@ describe('provisionCloudflareResources — Queues (resource only)', () => { ).rejects.toThrow(/queues info jobs.*failed/s); expect(calls.some((c) => isCall(c, 'queues', 'create'))).toBe(false); }); + + it('does NOT create on a generic/unrelated "not found" (e.g. Account not found) — fails closed (F5)', async () => { + // The OLD broad `/not found|no such/` regex misread "Account not found" as + // queue absence and blindly created. It must fail closed instead. + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { + match: /^queues info jobs/, + reply: () => fail('', 'X [ERROR] Account not found [code: 10000]'), + }, + ]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, queues: ['jobs'] }, + { runWrangler: run, log: silentLog }, + ), + ).rejects.toThrow(/queues info jobs.*failed/s); + expect(calls.some((c) => isCall(c, 'queues', 'create'))).toBe(false); + }); + + it('is name-aware: a "does not exist" for a DIFFERENT queue name is NOT absence for this one (F5)', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { + match: /^queues info jobs/, + reply: () => fail('', 'X [ERROR] Queue "some-other-queue" does not exist.'), + }, + ]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, queues: ['jobs'] }, + { runWrangler: run, log: silentLog }, + ), + ).rejects.toThrow(/queues info jobs.*failed/s); + expect(calls.some((c) => isCall(c, 'queues', 'create'))).toBe(false); + }); + + it('creates ONLY on the exact `Queue "" does not exist` signal for THAT name (F5)', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { + match: /^queues info jobs/, + reply: () => fail('', 'X [ERROR] Queue "jobs" does not exist.'), + }, + { match: /^queues create jobs/, reply: () => ok('Created queue jobs') }, + ]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, queues: ['jobs'] }, + { runWrangler: run, log: silentLog }, + ); + expect(calls).toEqual([ + ['queues', 'info', 'jobs', '-c', wranglerPath], + ['queues', 'create', 'jobs', '-c', wranglerPath], + ]); + expect(result.resources).toEqual([{ kind: 'queue', name: 'jobs', action: 'created' }]); + }); }); describe('provisionCloudflareResources — account reconciliation + config pin (F1)', () => { @@ -1080,6 +1251,67 @@ describe('provisionCloudflareResources — account reconciliation + config pin ( expect(restCalled).toBe(false); }); + it('persists account_id BEFORE any remote op so a later different-account retry fails closed (F1)', async () => { + // Run 1 under account A: D1 create writes a real id, THEN a later op fails. + setup(JSON.stringify({ name: 'app' })); + const run1 = makeRunner([ + { match: /^d1 list --json/, reply: () => ok('[]') }, + { + match: /^d1 create /, + reply: () => { + simulateUpdateConfig('d1_databases', { + binding: 'DB', + database_name: 'myapp-db', + database_id: 'created-under-A', + }); + return ok(); + }, + }, + // A later op fails → the run throws AFTER the D1 id was already written. + { + match: /^queues info jobs/, + reply: () => fail('', 'X [ERROR] Authentication error [code: 10000]'), + }, + ]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' }, queues: ['jobs'] }, + { runWrangler: run1.run, accountId: 'acct-A', log: silentLog }, + ), + ).rejects.toThrow(/queues info jobs.*failed/s); + // account_id was pinned to A UP FRONT (before D1), alongside the real D1 id. + const after1 = readConfig(); + expect(after1.account_id).toBe('acct-A'); + expect((after1.d1_databases as Array>)[0].database_id).toBe( + 'created-under-A', + ); + + // Run 2 retries under account B → must fail closed at reconciliation with NO + // wrangler/REST calls (never rebinds account-A's D1 id under account B). + let restCalled = false; + const run2 = makeRunner([{ match: /.*/, reply: () => ok() }]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' }, queues: ['jobs'] }, + { + runWrangler: run2.run, + accountId: 'acct-B', + log: silentLog, + listHyperdriveConfigs: async () => { + restCalled = true; + return []; + }, + }, + ), + ).rejects.toThrow(/account mismatch.*acct-A.*acct-B/s); + expect(run2.calls).toEqual([]); + expect(restCalled).toBe(false); + }); + it('passes -c to EVERY wrangler invocation (list, info, create)', async () => { setup(JSON.stringify({ name: 'app' })); const { run, calls } = makeRunner([ @@ -1318,6 +1550,86 @@ describe('provisionCloudflareResources — Hyperdrive discovery (F2 pagination + ).rejects.toThrow(/cannot adopt Hyperdrive config "myapp-hd".*share that name/s); expect(calls.some((c) => isCall(c, 'hyperdrive', 'create'))).toBe(false); }); + + it('fails closed on malformed pagination (missing/premature-empty/duplicate/inconsistent) — never declares absence, no create', async () => { + // Each case would let the OLD loop stop early and declare "absent" without + // proving completeness → a same-named config on an unread page becomes a + // duplicate. All must fail closed BEFORE any create. + const cases: Array> = [ + // 1. missing result_info.total_count metadata (cannot prove completeness) + [{ body: { success: true, result: [{ id: 'a', name: 'other' }] } }], + // 2. premature empty page before all configs are listed + [ + { + body: { + success: true, + result: [{ id: 'a', name: 'other' }], + result_info: { total_count: 3 }, + }, + }, + { body: { success: true, result: [], result_info: { total_count: 3 } } }, + ], + // 3. repeated/duplicate id across pages (padding to satisfy a count) + [ + { + body: { + success: true, + result: [{ id: 'a', name: 'x' }], + result_info: { total_count: 2 }, + }, + }, + { + body: { + success: true, + result: [{ id: 'a', name: 'x' }], + result_info: { total_count: 2 }, + }, + }, + ], + // 4. inconsistent total_count between pages + [ + { + body: { + success: true, + result: [{ id: 'a', name: 'x' }], + result_info: { total_count: 2 }, + }, + }, + { + body: { + success: true, + result: [{ id: 'b', name: 'y' }], + result_info: { total_count: 5 }, + }, + }, + ], + ]; + for (const pages of cases) { + setup(JSON.stringify({ account_id: 'acct', name: 'app' })); + const { restore } = stubFetch(pages); + try { + const { run, calls } = makeRunner([]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + hyperdrive: { + binding: 'HYPERDRIVE', + name: 'myapp-hd', + connectionString: 'postgres://x', + }, + }, + { runWrangler: run, apiToken: 'tok', log: silentLog }, + ), + ).rejects.toThrow(/cannot verify whether Hyperdrive config "myapp-hd"/); + expect(calls.some((c) => isCall(c, 'hyperdrive', 'create'))).toBe(false); + } finally { + restore(); + } + } + }); }); describe('provisionCloudflareResources — KV/R2 fail closed on schema-valid ambiguity (F3)', () => { @@ -1337,6 +1649,27 @@ describe('provisionCloudflareResources — KV/R2 fail closed on schema-valid amb expect(calls.some((c) => isCall(c, 'kv', 'namespace', 'create'))).toBe(false); }); + it('KV: fails closed (no create) when the list has a malformed member (null) — never filtered away', async () => { + // The OLD strict parser filtered `null` out, leaving a false "absent" match → + // duplicate. The malformed member must reject the WHOLE list instead. + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { + match: /^kv namespace list/, + reply: () => ok(JSON.stringify([null, { id: 'other', title: 'someone-else' }])), + }, + ]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, kvNamespaces: [{ binding: 'KV', title: 'myapp-kv' }] }, + { runWrangler: run, log: silentLog }, + ), + ).rejects.toThrow(/malformed KV namespace entry/); + expect(calls.some((c) => isCall(c, 'kv', 'namespace', 'create'))).toBe(false); + }); + it('R2: does NOT adopt when `r2 bucket info` exits 0 but the payload names a different bucket', async () => { setup(JSON.stringify({ name: 'app' })); const { run, calls } = makeRunner([ From aee906f1f323f8f906b516d9d3ad4b79c0b3c2c1 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 16 Jul 2026 16:25:24 +0800 Subject: [PATCH 020/128] fix(provision): close 6 codex round-4 findings in the CF resource provisioner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Structural fixes (one shared mechanism per class, no per-instance patching): - F1 (critical): scrub CLOUDFLARE_ENV in provisionEnv; resolve the effective account as config.account_id ?? opts.accountId ?? ambient CLOUDFLARE_ACCOUNT_ID; fail closed on the default (real-wrangler) path when none is resolvable; persist the resolved account via the Task 3 writer BEFORE the first remote side effect (retained round-2/3 reconciliation). - F2: one resource-name validator (^[A-Za-z0-9][A-Za-z0-9_-]{0,62}$) applied to every argv-positional name up front, so an option-like name (--help/-h) can never be parsed as a wrangler flag. - F3: RunWranglerResult carries structured {signal,error} provenance; one rule (isHardFailure / isCleanSuccess / isCleanFailure) gates every discovery, absence, success and adopt path — a signal-killed/unspawnable result is never "absent" or "success". - F4: postcondition-verify after every config-writing create (d1/kv/r2/hyperdrive) re-reads the config and confirms a real id landed, backstopping wrangler's bin.wrangler wrapper mapping a killed child to exit 0. - F5: one strict D1 identity normalizer (empty-string aliases treated as absent, prefer database_id, reject conflicting ids) shared by d1 list + d1 info, with an exact requested-name match required before adopt. - F6: the binding-name collector rejects (throws) a whole config when any binding-bearing array member is malformed, instead of dropping it and missing a real collision. Adds 18 tests (217 pass across provision+wrangler+preset). deploy.ts + preset.ts untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/cli/provision.ts | 449 +++++++++++++++++----- packages/void/src/cli/wrangler.ts | 81 +++- packages/void/test/unit/provision.test.ts | 391 +++++++++++++++++++ packages/void/test/unit/wrangler.test.ts | 53 +++ 4 files changed, 865 insertions(+), 109 deletions(-) diff --git a/packages/void/src/cli/provision.ts b/packages/void/src/cli/provision.ts index 2ab0a36bb..a8e1115bf 100644 --- a/packages/void/src/cli/provision.ts +++ b/packages/void/src/cli/provision.ts @@ -27,8 +27,26 @@ export interface DesiredResourceSet { hyperdrive?: { binding: string; name: string; connectionString: string }; } -/** Result of one `runWrangler` invocation. */ -export type RunWranglerResult = { stdout: string; stderr: string; status: number }; +/** + * Result of one `runWrangler` invocation, carrying STRUCTURED PROVENANCE so a + * signal-killed or unspawnable child is never mistaken for a clean exit. The + * single rule ({@link isHardFailure}) every classifier defers to: a result with a + * non-null `signal` or a `error` did NOT run to completion, so its buffered + * status/stdout/stderr is unreliable and must NEVER be read as "absent" or + * "success". + */ +export type RunWranglerResult = { + stdout: string; + stderr: string; + status: number; + /** + * Non-null ONLY when the child was terminated by a signal — a HARD failure. + * (Optional so injected test stubs may omit it, meaning "clean".) + */ + signal?: NodeJS.Signals | null; + /** The spawn-error message when the child could not be started — a HARD failure. */ + error?: string | null; +}; /** * Seam over shelling out to the project's `wrangler`. The default implementation @@ -118,12 +136,19 @@ const defaultLogger: ProvisionLogger = { * process (which may in turn run user hooks). The cloudflared `CF_ACCESS_TOKEN` * JWT (dev path) is left untouched. */ -function provisionEnv(accountId?: string): NodeJS.ProcessEnv { +export function provisionEnv(accountId?: string): NodeJS.ProcessEnv { const env = { ...process.env }; delete env.CF_ACCESS_CLIENT_ID; delete env.CF_ACCESS_CLIENT_SECRET; - // Select the target account non-interactively (CI-safe) when known, so - // `wrangler list/create` never prompts for account disambiguation. + // Scrub CLOUDFLARE_ENV: it selects an ENVIRONMENT-specific config block, whose + // own `account_id` (and bindings) could differ from the single account this + // provision pinned — a footgun. Removing it forces wrangler onto the top-level + // (pinned) config/account so `wrangler list/create` cannot silently drift + // to an env-specific account we never resolved or persisted. + delete env.CLOUDFLARE_ENV; + // Pin the target account non-interactively (CI-safe) when known, so + // `wrangler list/create` never prompts for disambiguation AND never falls + // back to a DIFFERENT ambient account than the one we resolved + persisted. if (accountId) { env.CLOUDFLARE_ACCOUNT_ID = accountId; } @@ -191,7 +216,40 @@ export function normalizeRunWranglerResult(result: { : (result.status as number); const baseStderr = result.stderr ?? ''; const stderr = notes.length > 0 ? [baseStderr, ...notes].filter(Boolean).join('\n') : baseStderr; - return { stdout, stderr, status }; + // Preserve the provenance STRUCTURALLY (not just folded into stderr text) so + // downstream classifiers can distinguish a clean non-zero exit (which MAY carry + // a not-found signal) from a kill/spawn-error (which never may). + return { + stdout, + stderr, + status, + signal: result.signal ?? null, + error: result.error != null ? result.error.message : null, + }; +} + +/** + * THE single provenance rule. A result is a HARD failure when the child was + * signal-killed or could not be spawned: it did not run to completion, so its + * buffered status/stdout/stderr is unreliable and must NEVER be read as "absent" + * or "success". Every success/absence/adopt classifier below defers to this. + */ +function isHardFailure(result: RunWranglerResult): boolean { + return result.signal != null || result.error != null; +} + +/** The child ran to completion and reported SUCCESS (a clean numeric exit 0). */ +function isCleanSuccess(result: RunWranglerResult): boolean { + return !isHardFailure(result) && result.status === 0; +} + +/** + * The child ran to completion and reported a FAILURE status (a numeric non-zero + * exit, no signal, no spawn error) — the ONLY case whose stdout/stderr may be + * classified for a resource-specific not-found signal. + */ +function isCleanFailure(result: RunWranglerResult): boolean { + return !isHardFailure(result) && typeof result.status === 'number' && result.status !== 0; } /** @@ -407,7 +465,7 @@ function escapeRegExp(value: string): string { * BEFORE parsing stdout, so a failed result can never be parsed as "absent". */ function assertDiscoverySucceeded(result: RunWranglerResult, label: string): void { - if (typeof result.status !== 'number' || result.status !== 0) { + if (!isCleanSuccess(result)) { throw new Error( `provision: "${label}" failed (exit ${String(result.status)}): ` + `${result.stderr || result.stdout || 'no output'}. Cannot verify existing resources; ` + @@ -474,19 +532,44 @@ function validateResourceList( return validated; } -/** `wrangler d1 list --json` / `d1 info --json` → `{ id, name }`. */ -function extractD1( +/** + * ONE strict D1 identity normalizer, shared by `d1 list --json` and + * `d1 info --json`. Requires EXACTLY ONE unambiguous NON-EMPTY id: + * - reads BOTH aliases (`database_id`, `uuid`), treating an EMPTY STRING as + * ABSENT for each — so an empty-string `uuid` can no longer shadow a valid + * `database_id` (`'' ?? x === ''` in the old `??` chain made a validated DB + * look absent → duplicate create); + * - prefers `database_id` when both are present and equal; + * - REJECTS conflicting non-empty aliases (two distinct ids → returns + * `undefined`, so the caller does not adopt an ambiguous identity). + * The `name` (from `database_name`/`name`, empty treated as absent) is returned so + * callers can require an EXACT requested-name match before adopting. + */ +function normalizeD1Identity( entry: Record | undefined, ): { id: string; name?: string } | undefined { if (!entry) { return undefined; } - const id = entry.uuid ?? entry.database_id; - if (typeof id !== 'string' || id.length === 0) { + const ids = [entry.database_id, entry.uuid].filter( + (value): value is string => typeof value === 'string' && value.length > 0, + ); + // 0 distinct → no id; >1 distinct → conflicting/ambiguous. Either way, not a + // usable identity: the caller must fail closed rather than adopt/duplicate. + if (new Set(ids).size !== 1) { return undefined; } - const name = entry.name ?? entry.database_name; - return { id, name: typeof name === 'string' ? name : undefined }; + const name = [entry.database_name, entry.name].find( + (value): value is string => typeof value === 'string' && value.length > 0, + ); + return { id: ids[0], name }; +} + +/** The requested-name value carried by a `d1 list` member (either alias). */ +function d1EntryName(entry: Record): string | undefined { + return [entry.name, entry.database_name].find( + (value): value is string => typeof value === 'string' && value.length > 0, + ); } /** @@ -527,6 +610,13 @@ function isAlreadyExistsError(result: RunWranglerResult): boolean { * which would mint a duplicate resource. */ function isConfirmedAbsent(result: RunWranglerResult, kind: 'r2' | 'queue', name: string): boolean { + // Provenance rule: only a CLEAN non-zero exit may be classified. A signal-killed + // or unspawnable probe is a HARD failure — its buffered text (which may contain a + // not-found line printed just before the kill) is NOT proof of absence, so we + // refuse to authorize a create on it. + if (!isCleanFailure(result)) { + return false; + } const text = `${result.stdout}\n${result.stderr}`; switch (kind) { case 'r2': @@ -565,6 +655,71 @@ function assertD1MigrationsDirApplied( } } +/** Which config field + id key a CONFIG-WRITING create must have populated. */ +interface CreatePostcondition { + /** Config array field the create's `--update-config` upserts into. */ + field: string; + /** The member key that must hold a REAL (non-placeholder) id afterward. */ + idField: string; + /** Human resource name for the message. */ + resource: string; + /** Command name for the message. */ + command: string; +} + +/** + * F4 backstop — verify a create's POSTCONDITION. wrangler 4.107's `bin.wrangler` + * is a WRAPPER that spawns `wrangler-dist/cli.js` and maps a signal-killed child + * (`code === null`) to `process.exit(0)`, so `spawnSync` can see a `create` report + * "success" though the real CLI was killed and `--update-config` wrote NO id. + * After every config-writing create (d1/kv/r2/hyperdrive) reports success we + * RE-READ the resolved config and CONFIRM the target binding now holds a REAL id; + * otherwise we FAIL CLOSED. This also catches any partial/silent create failure, + * not only kills. (Queue creates write NO config, so they cannot be verified this + * way — a killed queue-create self-heals on the next read-first/probe run, which + * re-detects absence and re-creates.) + */ +function assertCreateWroteRealId( + configPath: string, + spec: CreatePostcondition, + binding: string, + name: string, +): void { + const entry = findBindingEntry(readRootConfig(configPath)[spec.field], binding); + if (!entry || !isRealValue(entry[spec.idField])) { + throw new Error( + `provision: "${spec.command} ${name}" reported success but "${configPath}" holds no real ` + + `${spec.resource} id for binding ${JSON.stringify(binding)} afterward. The create may have ` + + `been interrupted — wrangler's launcher can mask a signal-killed child as exit 0. Refusing ` + + `to record an unprovisioned resource; inspect the account/config and retry.`, + ); + } +} + +/** + * F2 — Cloudflare resource-name grammar enforced on EVERY name placed as a + * wrangler ARGV POSITIONAL (D1 db name, KV title, R2 bucket, Hyperdrive name, + * queue name) BEFORE it reaches argv. Shell-free spawning stops shell injection + * but NOT CLI OPTION injection: a name like `--help`, `--config`, or `-h` would be + * parsed by wrangler as a FLAG (and a queue name is derived from a filename → it + * is attacker-influenced). We require the name to START with an ASCII letter or + * digit and contain only ASCII letters, digits, `-` and `_`, 1–63 chars. That + * rejects any leading `-` (the flag vector) plus whitespace/metacharacters, and is + * a safe superset of Cloudflare's per-resource naming rules. Fail closed on any + * violation — an option-like name never reaches wrangler. + */ +const RESOURCE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,62}$/; +function assertValidResourceName(kind: string, name: string): void { + if (typeof name !== 'string' || !RESOURCE_NAME_RE.test(name)) { + throw new Error( + `provision: refusing to pass an invalid ${kind} name ${JSON.stringify(name)} to wrangler. ` + + `Resource names must be 1–63 characters, start with a letter or digit, and contain only ` + + `letters, digits, "-" or "_" (a name beginning with "-" would be parsed as a CLI flag). ` + + `Rename the resource and retry.`, + ); + } +} + /** * Ensure every resource in `desired` exists in the user's own Cloudflare account, * creating the missing ones by shelling out to `wrangler` and letting @@ -591,21 +746,42 @@ export async function provisionCloudflareResources( // ONE exact config path threaded through read-first, wrangler `-c`, AND the // writer, so reads and writes can never hit different files (no split-brain). const configPath = rootWranglerPath; - // Resolve the ONE effective Cloudflare account BEFORE any read-first / create / - // REST. wrangler 4.107 gives the resolved config's `account_id` PRECEDENCE over - // the CLOUDFLARE_ACCOUNT_ID env var, so if the config pins account X while - // `opts.accountId` requests Y, wrangler create/list would target X while the - // Hyperdrive REST existence check targets Y — a cross-account create/orphan. - // Fail closed on a real mismatch; otherwise pin `config.account_id ?? - // opts.accountId` EVERYWHERE (the wrangler env AND the Hyperdrive REST call), so - // every path targets the same account. - const configAccountId = readRootConfig(configPath).account_id; - if ( - typeof configAccountId === 'string' && - configAccountId.length > 0 && - opts?.accountId && - configAccountId !== opts.accountId - ) { + + // --- F2: resource-name validation (BEFORE any argv / remote work) --------- + // Every name that becomes a wrangler ARGV POSITIONAL is validated up front, so an + // option-like name (`--help`, `-h`, `--config`) can never reach wrangler and be + // parsed as a flag. Fail closed here, before the account gate or any wrangler/REST + // call. + if (desired.d1) { + assertValidResourceName('D1 database', desired.d1.name); + } + for (const { title } of desired.kvNamespaces) { + assertValidResourceName('KV namespace', title); + } + if (desired.r2) { + assertValidResourceName('R2 bucket', desired.r2.bucketName); + } + if (desired.hyperdrive) { + assertValidResourceName('Hyperdrive config', desired.hyperdrive.name); + } + for (const queueName of desired.queues) { + assertValidResourceName('queue', queueName); + } + + // --- F1: resolve + pin the ONE effective Cloudflare account --------------- + // Precedence: the resolved config's `account_id` > `opts.accountId` > the ambient + // `CLOUDFLARE_ACCOUNT_ID`. wrangler 4.107 gives the resolved config's account_id + // PRECEDENCE over the env var, so if the config pins X while `opts.accountId` + // requests Y, wrangler create/list would target X while the Hyperdrive REST check + // targets Y — a cross-account create/orphan. Fail closed on a real mismatch; + // otherwise pin ONE value EVERYWHERE (the wrangler env, which scrubs CLOUDFLARE_ENV + // and overrides any different ambient account, AND the Hyperdrive REST call). + const rawConfigAccountId = readRootConfig(configPath).account_id; + const configAccountId = + typeof rawConfigAccountId === 'string' && rawConfigAccountId.length > 0 + ? rawConfigAccountId + : undefined; + if (configAccountId && opts?.accountId && configAccountId !== opts.accountId) { throw new Error( `provision: Cloudflare account mismatch — "${configPath}" pins account_id ` + `${JSON.stringify(configAccountId)} but deploy requested ${JSON.stringify(opts.accountId)}. ` + @@ -613,31 +789,58 @@ export async function provisionCloudflareResources( `and retry.`, ); } - const effectiveAccountId = - typeof configAccountId === 'string' && configAccountId.length > 0 - ? configAccountId - : opts?.accountId; - const runWrangler = - opts?.runWrangler ?? createDefaultRunWrangler(root, { accountId: effectiveAccountId }); + const ambientAccountId = + typeof process.env.CLOUDFLARE_ACCOUNT_ID === 'string' && + process.env.CLOUDFLARE_ACCOUNT_ID.length > 0 + ? process.env.CLOUDFLARE_ACCOUNT_ID + : undefined; + const effectiveAccountId = configAccountId ?? opts?.accountId ?? ambientAccountId; + const log = opts?.log ?? defaultLogger; const resources: Array = []; const write = ( patch: Parameters[1], ): WriteProvisionedWranglerResult => writeProvisionedWranglerConfig(root, patch, { configPath }); + // Any resource to ensure implies remote work (list/info/create/REST). + const hasRemoteWork = + !!desired.d1 || + desired.kvNamespaces.length > 0 || + !!desired.r2 || + !!desired.hyperdrive || + desired.queues.length > 0; + + // F1 fail-closed gate: the DEFAULT runWrangler passes the ambient + // CLOUDFLARE_ACCOUNT_ID to the REAL wrangler, so without a resolved account + // wrangler could create resources under an environment/ambient account we never + // persisted (a later retry under a different account would then rebind them). + // Require a known account before spawning it. The gate is scoped to the default + // path — the ONLY consumer of the ambient env; when a caller injects runWrangler + // (unit tests / a future orchestrator seam) it owns the account context. + if (!opts?.runWrangler && hasRemoteWork && !effectiveAccountId) { + throw new Error( + `provision: no Cloudflare account is resolvable — set "account_id" in your root wrangler ` + + `config or export CLOUDFLARE_ACCOUNT_ID before deploying. Refusing to provision resources ` + + `without a pinned account (wrangler would otherwise fall back to an ambient/interactive ` + + `account that is never persisted, so a later retry could rebind them elsewhere).`, + ); + } + const runWrangler = + opts?.runWrangler ?? createDefaultRunWrangler(root, { accountId: effectiveAccountId }); + // --- account_id (PERSIST FIRST, before any remote side effect) ----------- // Pin the effective account_id NOW — before any create writes a real id — so a - // partial run can never leave account-A's freshly-minted ids in a config with - // NO account pin. Without this, a later retry under account B would see the real - // id (read-first hit → skips discovery) and finally stamp `account_id = B`, - // binding account-A resources under account B. With the pin written up front, a - // different-account retry instead hits the reconciliation above (config account - // ≠ requested → fail closed) before touching wrangler/REST. `effectiveAccountId` - // equals `opts.accountId` whenever the latter is set (a config/opts mismatch has - // already thrown), so this writes exactly the requested account. + // partial run can never leave freshly-minted ids in a config with NO account pin. + // Without this, a later retry under a DIFFERENT account would see the real id + // (read-first hit → skips discovery) and finally stamp its own `account_id`, + // binding the first account's resources under the second. With the pin written up + // front, a different-account retry instead hits the reconciliation above (config + // account ≠ requested → fail closed) before touching wrangler/REST. Skip the write + // when the config ALREADY pins that exact account (keeps byte-identical configs + // untouched). let accountIdWritten = false; - if (opts?.accountId) { - write({ accountId: opts.accountId }); + if (effectiveAccountId && configAccountId !== effectiveAccountId) { + write({ accountId: effectiveAccountId }); accountIdWritten = true; } @@ -675,7 +878,20 @@ export async function provisionCloudflareResources( nameField: 'name', nameLabel: 'named', }); - const existing = extractD1(databases.find((db) => db.name === name)); + // F5: find the member whose name matches the request, then normalize its + // identity strictly. If the target-named member exists but its id is + // ambiguous/empty (e.g. an empty-string `uuid` that used to shadow a valid + // `database_id`, or conflicting aliases), FAIL CLOSED — never fall through to + // create (a duplicate) and never adopt an ambiguous id. + const namedMatch = databases.find((db) => d1EntryName(db) === name); + const existing = normalizeD1Identity(namedMatch); + if (namedMatch && !existing) { + throw new Error( + `provision: "wrangler d1 list" returned a D1 database named ${JSON.stringify(name)} with ` + + `an ambiguous or empty id (conflicting/blank uuid/database_id). Cannot adopt it, and ` + + `refusing to create a possible duplicate; inspect the account and retry.`, + ); + } if (existing) { log.info(`adopted existing D1 database ${name} (${existing.id})`); const res = write({ @@ -695,40 +911,53 @@ export async function provisionCloudflareResources( '-c', configPath, ]); - if (created.status !== 0) { - if (isAlreadyExistsError(created)) { - // TOCTOU: created between our list and create (code 7502). Capture - // the id via `d1 info` — which must SUCCEED (gate) and POSITIVELY - // identify THIS database (name match) before we adopt its id. - const infoResult = runWrangler(['d1', 'info', name, '--json', '-c', configPath], { - captureJson: true, - }); - assertDiscoverySucceeded(infoResult, `wrangler d1 info ${name}`); - const info = extractD1(parseJsonObject(infoResult.stdout)); - if (!info || (info.name !== undefined && info.name !== name)) { - throw new Error( - `provision: "wrangler d1 create ${name}" reported the database already exists, ` + - `but "wrangler d1 info ${name} --json" did not return a matching database id/name.`, - ); - } - log.info(`adopted existing D1 database ${name} (${info.id})`); - const res = write({ - d1: { binding, databaseName: name, databaseId: info.id }, - d1MigrationsDir: { binding, dir: D1_MIGRATIONS_DIR }, - }); - assertD1MigrationsDirApplied(res, binding, configPath); - resources.push({ kind: 'd1', binding, name, action: 'adopted', id: info.id }); - } else { + if (isCleanSuccess(created)) { + // wrangler wrote database_id/database_name via --update-config; VERIFY the + // real id landed (F4 postcondition), then fill the migrations_dir it does + // not write. + assertCreateWroteRealId( + configPath, + { + field: 'd1_databases', + idField: 'database_id', + resource: 'D1 database', + command: 'wrangler d1 create', + }, + binding, + name, + ); + const res = write({ d1MigrationsDir: { binding, dir: D1_MIGRATIONS_DIR } }); + assertD1MigrationsDirApplied(res, binding, configPath); + resources.push({ kind: 'd1', binding, name, action: 'created' }); + } else if (isCleanFailure(created) && isAlreadyExistsError(created)) { + // TOCTOU: created between our list and create (clean non-zero, code 7502). + // Capture the id via `d1 info` — which must SUCCEED (gate) and POSITIVELY + // identify THIS database (EXACT name match) before we adopt its id. + const infoResult = runWrangler(['d1', 'info', name, '--json', '-c', configPath], { + captureJson: true, + }); + assertDiscoverySucceeded(infoResult, `wrangler d1 info ${name}`); + const info = normalizeD1Identity(parseJsonObject(infoResult.stdout)); + if (!info || info.name !== name) { throw new Error( - `provision: "wrangler d1 create ${name}" failed: ${created.stderr || created.stdout}`, + `provision: "wrangler d1 create ${name}" reported the database already exists, ` + + `but "wrangler d1 info ${name} --json" did not return a database with a matching ` + + `name and an unambiguous id. Refusing to adopt an unverified database.`, ); } - } else { - // wrangler wrote database_id/database_name via --update-config; fill - // the migrations_dir it does not. - const res = write({ d1MigrationsDir: { binding, dir: D1_MIGRATIONS_DIR } }); + log.info(`adopted existing D1 database ${name} (${info.id})`); + const res = write({ + d1: { binding, databaseName: name, databaseId: info.id }, + d1MigrationsDir: { binding, dir: D1_MIGRATIONS_DIR }, + }); assertD1MigrationsDirApplied(res, binding, configPath); - resources.push({ kind: 'd1', binding, name, action: 'created' }); + resources.push({ kind: 'd1', binding, name, action: 'adopted', id: info.id }); + } else { + // Everything else — a hard failure (signal/spawn error) or a clean + // non-7502 error — fails closed. + throw new Error( + `provision: "wrangler d1 create ${name}" failed: ${created.stderr || created.stdout}`, + ); } } } @@ -792,11 +1021,24 @@ export async function provisionCloudflareResources( '-c', configPath, ]); - if (created.status !== 0) { + if (!isCleanSuccess(created)) { throw new Error( `provision: "wrangler kv namespace create ${title}" failed: ${created.stderr || created.stdout}`, ); } + // F4 postcondition: confirm --update-config wrote a REAL id (a killed create + // masked as exit 0 by wrangler's launcher would leave none). + assertCreateWroteRealId( + configPath, + { + field: 'kv_namespaces', + idField: 'id', + resource: 'KV namespace', + command: 'wrangler kv namespace create', + }, + binding, + title, + ); resources.push({ kind: 'kv', binding, name: title, action: 'created' }); } } @@ -813,10 +1055,11 @@ export async function provisionCloudflareResources( const info = runWrangler(['r2', 'bucket', 'info', bucketName, '--json', '-c', configPath], { captureJson: true, }); - if (info.status === 0) { - // Exit 0 is not enough to adopt — the success payload must POSITIVELY - // identify the requested bucket (its `name`) before we write the entry, - // else a mismatched/garbled 0-exit payload could bind the wrong bucket. + if (isCleanSuccess(info)) { + // A clean exit 0 is not enough to adopt — the success payload must + // POSITIVELY identify the requested bucket (its `name`) before we write the + // entry, else a mismatched/garbled 0-exit payload could bind the wrong + // bucket. (A signal-killed probe is NOT a clean success — see the rule.) const payload = parseJsonObject(info.stdout); if (!payload || payload.name !== bucketName) { throw new Error( @@ -830,7 +1073,8 @@ export async function provisionCloudflareResources( write({ r2: { binding, bucketName } }); resources.push({ kind: 'r2', binding, name: bucketName, action: 'adopted' }); } else if (isConfirmedAbsent(info, 'r2', bucketName)) { - // CONFIRMED not-found → create. `--update-config` upserts by binding. + // CONFIRMED not-found (clean non-zero + exact signal) → create. + // `--update-config` upserts by binding. const created = runWrangler([ 'r2', 'bucket', @@ -842,11 +1086,23 @@ export async function provisionCloudflareResources( '-c', configPath, ]); - if (created.status !== 0) { + if (!isCleanSuccess(created)) { throw new Error( `provision: "wrangler r2 bucket create ${bucketName}" failed: ${created.stderr || created.stdout}`, ); } + // F4 postcondition: confirm --update-config wrote a REAL bucket_name. + assertCreateWroteRealId( + configPath, + { + field: 'r2_buckets', + idField: 'bucket_name', + resource: 'R2 bucket', + command: 'wrangler r2 bucket create', + }, + binding, + bucketName, + ); resources.push({ kind: 'r2', binding, name: bucketName, action: 'created' }); } else { // A non-zero that is NOT the confirmed not-found signal (auth/metrics/ @@ -939,11 +1195,23 @@ export async function provisionCloudflareResources( '-c', configPath, ]); - if (created.status !== 0) { + if (!isCleanSuccess(created)) { throw new Error( `provision: "wrangler hyperdrive create ${name}" failed: ${created.stderr || created.stdout}`, ); } + // F4 postcondition: confirm --update-config wrote a REAL Hyperdrive id. + assertCreateWroteRealId( + configPath, + { + field: 'hyperdrive', + idField: 'id', + resource: 'Hyperdrive config', + command: 'wrangler hyperdrive create', + }, + binding, + name, + ); resources.push({ kind: 'hyperdrive', binding, name, action: 'created' }); } } @@ -957,21 +1225,24 @@ export async function provisionCloudflareResources( // app-class-aware and belongs to Task 6. for (const name of desired.queues) { const info = runWrangler(['queues', 'info', name, '-c', configPath]); - if (info.status === 0) { + if (isCleanSuccess(info)) { resources.push({ kind: 'queue', name, action: 'reused' }); continue; } - // A non-zero probe is "absent" (→ create) ONLY on the EXACT, name-aware - // not-found signal (`Queue "" does not exist`). Any other nonzero — a - // signal/spawn failure, an auth error, or a generic `Account not found` — is - // NOT absence and fails closed (never a blind create). + // A probe is "absent" (→ create) ONLY on a CLEAN non-zero exit carrying the + // EXACT, name-aware not-found signal (`Queue "" does not exist`). Any + // signal/spawn failure (HARD failure), an auth error, or a generic `Account not + // found` is NOT absence and fails closed (never a blind create). if (!isConfirmedAbsent(info, 'queue', name)) { throw new Error( `provision: "wrangler queues info ${name}" failed: ${info.stderr || info.stdout}`, ); } const created = runWrangler(['queues', 'create', name, '-c', configPath]); - if (created.status !== 0) { + // Queue creates write NO config, so they cannot be postcondition-verified; a + // killed queue-create self-heals on the next run (its `queues info` probe + // re-detects absence and re-creates). We still fail closed on a non-clean exit. + if (!isCleanSuccess(created)) { throw new Error( `provision: "wrangler queues create ${name}" failed: ${created.stderr || created.stdout}`, ); diff --git a/packages/void/src/cli/wrangler.ts b/packages/void/src/cli/wrangler.ts index 717aa41ae..c38db5983 100644 --- a/packages/void/src/cli/wrangler.ts +++ b/packages/void/src/cli/wrangler.ts @@ -1698,15 +1698,50 @@ const NAME_KEYED_BINDING_ARRAYS = new Set(['send_email', 'ratelimits']); * `getBindingNames` overlooks (see NAME_KEYED_BINDING_ARRAYS). */ function bindingNamesForField(field: string, value: unknown): Array { - const generic = genericBindingNames(value); if (NAME_KEYED_BINDING_ARRAYS.has(field) && Array.isArray(value)) { - const names = value - .filter(isRecord) - .map((entry) => entry.name) - .filter((name): name is string => typeof name === 'string'); - return [...generic, ...names]; - } - return generic; + // send_email / ratelimits: an array of `{ name }` (some also carry `.binding`). + // Collect BOTH — strictly. A malformed member (F6) REJECTS the whole config + // (fail closed) rather than being silently dropped, which would let the + // uniqueness guard miss a real binding name and commit a conflicting one. + const names = strictBindingArray(value, 'name', field); + const bindings = value + .filter((entry): entry is Record => isRecord(entry)) + .map((entry) => entry.binding) + .filter((binding): binding is string => typeof binding === 'string' && binding.length > 0); + return [...bindings, ...names]; + } + return genericBindingNames(value); +} + +/** + * F6 — strictly read `keyField` (a non-empty string) from EVERY member of a + * binding-bearing array. THROWS (fail closed) on ANY malformed member — never + * silently drops one. Dropping a malformed member could convert a mixed-validity + * array (e.g. `[null, { binding: 'DB', id: '…' }]`) into an empty/partial binding + * set, so the worker-wide uniqueness guard would MISS the existing `DB` and commit + * a conflicting binding — a fail-open in a documented invariant. Mirrors the + * writer's existing fail-closed-on-malformed-config philosophy. + */ +function strictBindingArray( + members: Array, + keyField: 'binding' | 'name', + field: string, +): Array { + return members.map((member, index) => { + if ( + !isRecord(member) || + typeof member[keyField] !== 'string' || + (member[keyField] as string).length === 0 + ) { + throw new Error( + `deploy: cannot verify binding-name uniqueness — "${field}" contains a malformed entry at ` + + `index ${index} (missing a non-empty string "${keyField}"). Refusing to proceed on a ` + + `partially-readable binding set (a dropped entry could hide a real collision). Fix the ` + + `wrangler config and retry.`, + ); + } + return member[keyField] as string; + }); } /** @@ -1721,19 +1756,25 @@ function genericBindingNames(value: unknown): Array { if (typeof value !== 'object' || value === null) { return []; } - if ( - isRecord(value) && - Array.isArray(value.bindings) && - value.bindings.every((binding) => isRecord(binding) && typeof binding.name === 'string') - ) { - return (value.bindings as Array<{ name: string }>).map((binding) => binding.name); - } - if ( - Array.isArray(value) && - value.every((entry) => isRecord(entry) && typeof entry.binding === 'string') - ) { - return (value as Array<{ binding: string }>).map((entry) => entry.binding); + // Shape 1 — binding list `{ bindings: [{ name }] }` (durable_objects, unsafe). + // Strict: once this IS a binding list (at least one record member), any malformed + // member throws (F6) rather than falling through to a partial/garbage read. The + // `some(isRecord)` guard keeps a plain record that merely has a scalar key named + // `bindings` (e.g. a `vars` entry) on the shape-4 key path, matching prior + // behavior for that pathological case. + if (isRecord(value) && Array.isArray(value.bindings) && value.bindings.some(isRecord)) { + return strictBindingArray(value.bindings, 'name', 'bindings'); + } + // Shape 2 — namespace list `[{ binding }]` (kv_namespaces, d1_databases, + // r2_buckets, hyperdrive, …). Strict: a mixed-validity array (e.g. + // `[null, { binding: 'DB' }]`) throws instead of silently dropping the null and + // losing the valid `DB` binding (F6). NAME_KEYED arrays (send_email, ratelimits) + // never reach here — `bindingNamesForField` handles them before delegating. + if (Array.isArray(value)) { + return strictBindingArray(value, 'binding', 'namespace list'); } + // Shape 3 + 4 — a single record with `binding` (ai/browser), or a plain record + // whose keys are the names (vars, blob/module maps). if (isRecord(value)) { if (value.binding !== undefined) { return typeof value.binding === 'string' ? [value.binding] : []; diff --git a/packages/void/test/unit/provision.test.ts b/packages/void/test/unit/provision.test.ts index a8ff7e0ad..3198410dd 100644 --- a/packages/void/test/unit/provision.test.ts +++ b/packages/void/test/unit/provision.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from 'vite-plus/test'; import { normalizeRunWranglerResult, provisionCloudflareResources, + provisionEnv, type DesiredResourceSet, type RunWrangler, type RunWranglerResult, @@ -1717,4 +1718,394 @@ describe('normalizeRunWranglerResult — signal / spawn-error handling (F5)', () expect(normalizeRunWranglerResult({ status: 0, stdout: 'ok' }).status).toBe(0); expect(normalizeRunWranglerResult({ status: 2, stderr: 'boom' }).status).toBe(2); }); + + it('carries structured signal/error provenance (round-4 F3)', () => { + const killed = normalizeRunWranglerResult({ status: null, signal: 'SIGTERM' }); + expect(killed.signal).toBe('SIGTERM'); + expect(killed.error).toBeNull(); + const errored = normalizeRunWranglerResult({ status: null, error: new Error('ENOENT') }); + expect(errored.error).toContain('ENOENT'); + const clean = normalizeRunWranglerResult({ status: 0, stdout: 'ok' }); + expect(clean.signal).toBeNull(); + expect(clean.error).toBeNull(); + }); +}); + +describe('provisionCloudflareResources — F1 account gate + env scrub (round 4)', () => { + it('provisionEnv scrubs CLOUDFLARE_ENV + the CF Access token pair and pins the account', () => { + const prevEnv = process.env.CLOUDFLARE_ENV; + const prevId = process.env.CF_ACCESS_CLIENT_ID; + const prevSecret = process.env.CF_ACCESS_CLIENT_SECRET; + process.env.CLOUDFLARE_ENV = 'staging'; + process.env.CF_ACCESS_CLIENT_ID = 'id'; + process.env.CF_ACCESS_CLIENT_SECRET = 'secret'; + try { + const env = provisionEnv('acct-X'); + // CLOUDFLARE_ENV would let wrangler select an env-specific account block. + expect(env.CLOUDFLARE_ENV).toBeUndefined(); + expect(env.CF_ACCESS_CLIENT_ID).toBeUndefined(); + expect(env.CF_ACCESS_CLIENT_SECRET).toBeUndefined(); + expect(env.CLOUDFLARE_ACCOUNT_ID).toBe('acct-X'); + } finally { + const restore = (k: string, v: string | undefined): void => { + if (v === undefined) { + delete process.env[k]; + } else { + process.env[k] = v; + } + }; + restore('CLOUDFLARE_ENV', prevEnv); + restore('CF_ACCESS_CLIENT_ID', prevId); + restore('CF_ACCESS_CLIENT_SECRET', prevSecret); + } + }); + + it('fails closed (no wrangler/REST) when no account is resolvable on the default path', async () => { + setup(JSON.stringify({ name: 'app' })); // no account_id + const prev = process.env.CLOUDFLARE_ACCOUNT_ID; + delete process.env.CLOUDFLARE_ACCOUNT_ID; // no ambient account either + try { + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, + // NO runWrangler injected, NO accountId → the DEFAULT (real) path. + { log: silentLog }, + ), + ).rejects.toThrow(/no Cloudflare account is resolvable/); + // The gate throws BEFORE any wrangler subprocess / REST — nothing persisted. + expect(readConfig().d1_databases).toBeUndefined(); + expect(readConfig().account_id).toBeUndefined(); + } finally { + if (prev === undefined) { + delete process.env.CLOUDFLARE_ACCOUNT_ID; + } else { + process.env.CLOUDFLARE_ACCOUNT_ID = prev; + } + } + }); + + it('persists the AMBIENT account before remote work; a later different-account retry fails closed', async () => { + setup(JSON.stringify({ name: 'app' })); + const prev = process.env.CLOUDFLARE_ACCOUNT_ID; + process.env.CLOUDFLARE_ACCOUNT_ID = 'ambient-A'; + try { + // Run 1: ambient A (no opts.accountId). D1 create writes a real id, THEN a + // later op fails → the run throws AFTER account_id was pinned to A up front. + const run1 = makeRunner([ + { match: /^d1 list --json/, reply: () => ok('[]') }, + { + match: /^d1 create /, + reply: () => { + simulateUpdateConfig('d1_databases', { + binding: 'DB', + database_name: 'myapp-db', + database_id: 'created-under-A', + }); + return ok(); + }, + }, + { + match: /^queues info jobs/, + reply: () => fail('', 'X [ERROR] Authentication error [code: 10000]'), + }, + ]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' }, queues: ['jobs'] }, + { runWrangler: run1.run, log: silentLog }, + ), + ).rejects.toThrow(/queues info jobs.*failed/s); + const after1 = readConfig(); + expect(after1.account_id).toBe('ambient-A'); + expect((after1.d1_databases as Array>)[0].database_id).toBe( + 'created-under-A', + ); + + // Run 2 retries under account B → reconciliation A≠B fails closed, NO + // wrangler/REST (never rebinds account-A's real D1 id under account B). + let restCalled = false; + const run2 = makeRunner([{ match: /.*/, reply: () => ok() }]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' }, queues: ['jobs'] }, + { + runWrangler: run2.run, + accountId: 'acct-B', + log: silentLog, + listHyperdriveConfigs: async () => { + restCalled = true; + return []; + }, + }, + ), + ).rejects.toThrow(/account mismatch.*ambient-A.*acct-B/s); + expect(run2.calls).toEqual([]); + expect(restCalled).toBe(false); + } finally { + if (prev === undefined) { + delete process.env.CLOUDFLARE_ACCOUNT_ID; + } else { + process.env.CLOUDFLARE_ACCOUNT_ID = prev; + } + } + }); +}); + +describe('provisionCloudflareResources — F2 resource-name validation (round 4)', () => { + const badNames = ['--help', '--config', '-h']; + const kinds: Array<{ + label: RegExp; + desired: (name: string) => DesiredResourceSet; + createPrefix: Array; + }> = [ + { + label: /invalid D1 database name/, + desired: (name) => ({ ...emptyDesired, d1: { binding: 'DB', name } }), + createPrefix: ['d1', 'create'], + }, + { + label: /invalid KV namespace name/, + desired: (name) => ({ ...emptyDesired, kvNamespaces: [{ binding: 'KV', title: name }] }), + createPrefix: ['kv', 'namespace', 'create'], + }, + { + label: /invalid R2 bucket name/, + desired: (name) => ({ ...emptyDesired, r2: { binding: 'STORAGE', bucketName: name } }), + createPrefix: ['r2', 'bucket', 'create'], + }, + { + label: /invalid Hyperdrive config name/, + desired: (name) => ({ + ...emptyDesired, + hyperdrive: { binding: 'HYPERDRIVE', name, connectionString: 'postgres://x' }, + }), + createPrefix: ['hyperdrive', 'create'], + }, + { + label: /invalid queue name/, + desired: (name) => ({ ...emptyDesired, queues: [name] }), + createPrefix: ['queues', 'create'], + }, + ]; + + it('rejects option-like names for EVERY resource kind before any wrangler call', async () => { + for (const bad of badNames) { + for (const kind of kinds) { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([{ match: /.*/, reply: () => ok() }]); + await expect( + provisionCloudflareResources(tmpDir, wranglerPath, kind.desired(bad), { + runWrangler: run, + log: silentLog, + listHyperdriveConfigs: async () => [], + }), + ).rejects.toThrow(kind.label); + // No wrangler invocation at all — the name never reached argv. + expect(calls).toEqual([]); + } + } + }); +}); + +describe('provisionCloudflareResources — F3 signal-killed probe never authorizes create (round 4)', () => { + it('queues: a SIGTERM-killed probe with buffered "does not exist" text fails closed (no create)', async () => { + setup(JSON.stringify({ name: 'app' })); + const killed = normalizeRunWranglerResult({ + stdout: '', + stderr: 'X [ERROR] Queue "jobs" does not exist.', + status: null, + signal: 'SIGTERM', + }); + const { run, calls } = makeRunner([{ match: /^queues info jobs/, reply: () => killed }]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, queues: ['jobs'] }, + { runWrangler: run, log: silentLog }, + ), + ).rejects.toThrow(/queues info jobs.*failed/s); + expect(calls.some((c) => isCall(c, 'queues', 'create'))).toBe(false); + }); + + it('R2: a SIGTERM-killed probe with buffered 10006 text fails closed (no create)', async () => { + setup(JSON.stringify({ name: 'app' })); + const killed = normalizeRunWranglerResult({ + stdout: '', + stderr: 'X [ERROR] The bucket is not present [code: 10006]', + status: null, + signal: 'SIGTERM', + }); + const { run, calls } = makeRunner([{ match: /^r2 bucket info /, reply: () => killed }]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, r2: { binding: 'STORAGE', bucketName: 'myapp-uploads' } }, + { runWrangler: run, log: silentLog }, + ), + ).rejects.toThrow(/r2 bucket info myapp-uploads.*(failed|refusing)/s); + expect(calls.some((c) => isCall(c, 'r2', 'bucket', 'create'))).toBe(false); + }); +}); + +describe('provisionCloudflareResources — F4 create postcondition (round 4)', () => { + it('KV: a create that reports success but writes NO id fails closed', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { match: /^kv namespace list/, reply: () => ok('[]') }, + // Success exit, but nothing written to config (simulates a killed child the + // wrapper masked as exit 0, or a silent partial create). + { match: /^kv namespace create /, reply: () => ok() }, + ]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, kvNamespaces: [{ binding: 'KV', title: 'myapp-kv' }] }, + { runWrangler: run, log: silentLog }, + ), + ).rejects.toThrow(/reported success but .* holds no real KV namespace id/s); + expect(calls.some((c) => isCall(c, 'kv', 'namespace', 'create'))).toBe(true); + expect(readConfig().kv_namespaces).toBeUndefined(); + }); + + it('D1: a create that reports success but writes NO id fails closed', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run } = makeRunner([ + { match: /^d1 list --json/, reply: () => ok('[]') }, + { match: /^d1 create /, reply: () => ok() }, + ]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, + { runWrangler: run, log: silentLog }, + ), + ).rejects.toThrow(/reported success but .* holds no real D1 database id/s); + expect(readConfig().d1_databases).toBeUndefined(); + }); + + it('R2: a create that reports success but writes NO bucket fails closed', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run } = makeRunner([ + { match: /^r2 bucket info /, reply: () => fail('', 'The specified bucket does not exist') }, + { match: /^r2 bucket create /, reply: () => ok() }, + ]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, r2: { binding: 'STORAGE', bucketName: 'myapp-uploads' } }, + { runWrangler: run, log: silentLog }, + ), + ).rejects.toThrow(/reported success but .* holds no real R2 bucket id/s); + expect(readConfig().r2_buckets).toBeUndefined(); + }); + + it('Hyperdrive: a create that reports success but writes NO id fails closed', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run } = makeRunner([{ match: /^hyperdrive create /, reply: () => ok() }]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + hyperdrive: { binding: 'HYPERDRIVE', name: 'myapp-hd', connectionString: 'postgres://x' }, + }, + { runWrangler: run, log: silentLog, listHyperdriveConfigs: async () => [] }, + ), + ).rejects.toThrow(/reported success but .* holds no real Hyperdrive config id/s); + expect(readConfig().hyperdrive).toBeUndefined(); + }); +}); + +describe('provisionCloudflareResources — F5 strict D1 identity (round 4)', () => { + it('adopts (not create) a list member with uuid:"" but a valid database_id', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { + match: /^d1 list --json/, + // Empty-string uuid must NOT shadow the valid database_id. + reply: () => + ok(JSON.stringify([{ uuid: '', database_id: 'real-db-id', name: 'myapp-db' }])), + }, + ]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, + { runWrangler: run, log: silentLog }, + ); + expect(calls.some((c) => isCall(c, 'd1', 'create'))).toBe(false); + const d1 = readConfig().d1_databases as Array>; + expect(d1[0].database_id).toBe('real-db-id'); + expect(result.resources[0]).toMatchObject({ action: 'adopted', id: 'real-db-id' }); + }); + + it('fails closed when the target-named list member has conflicting non-empty ids (ambiguous)', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { + match: /^d1 list --json/, + reply: () => ok(JSON.stringify([{ uuid: 'id-a', database_id: 'id-b', name: 'myapp-db' }])), + }, + ]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, + { runWrangler: run, log: silentLog }, + ), + ).rejects.toThrow(/ambiguous or empty id/); + expect(calls.some((c) => isCall(c, 'd1', 'create'))).toBe(false); + }); + + it('rejects a 7502-collision `d1 info` payload with an id but NO name (unverified) — no adopt', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run } = makeRunner([ + { match: /^d1 list --json/, reply: () => ok('[]') }, + { match: /^d1 create /, reply: () => fail('', 'X [ERROR] already exists [code: 7502]') }, + { match: /^d1 info myapp-db --json/, reply: () => ok(JSON.stringify({ database_id: 'x' })) }, + ]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, + { runWrangler: run, log: silentLog }, + ), + ).rejects.toThrow(/did not return a database with a matching name/); + expect(readConfig().d1_databases).toBeUndefined(); + }); + + it('rejects a 7502-collision `d1 info` payload whose name MISMATCHES the request — no adopt', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run } = makeRunner([ + { match: /^d1 list --json/, reply: () => ok('[]') }, + { match: /^d1 create /, reply: () => fail('', 'X [ERROR] already exists [code: 7502]') }, + { + match: /^d1 info myapp-db --json/, + reply: () => ok(JSON.stringify({ database_id: 'x', name: 'a-different-db' })), + }, + ]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, + { runWrangler: run, log: silentLog }, + ), + ).rejects.toThrow(/did not return a database with a matching name/); + expect(readConfig().d1_databases).toBeUndefined(); + }); }); diff --git a/packages/void/test/unit/wrangler.test.ts b/packages/void/test/unit/wrangler.test.ts index b975d8f02..d4a98da4b 100644 --- a/packages/void/test/unit/wrangler.test.ts +++ b/packages/void/test/unit/wrangler.test.ts @@ -2305,6 +2305,59 @@ describe('writeProvisionedWranglerConfig', () => { expect(readRaw('wrangler.jsonc')).toBe(before); }); + it('adopt fails closed when a collected field has a malformed member masking a real binding (round-4 F6) — no write', () => { + // kv_namespaces holds a null member AND a valid { binding: 'DB' }. The collector + // must NOT silently drop the null and lose the 'DB' binding — adopting a D1 as + // 'DB' would then emit an undeployable cross-type duplicate. Reject the whole + // config (fail closed) instead of committing the conflicting binding. + setup( + 'wrangler.jsonc', + JSON.stringify({ kv_namespaces: [null, { binding: 'DB', id: 'kv-id' }] }), + ); + const before = readRaw('wrangler.jsonc'); + expect(() => + writeProvisionedWranglerConfig(tmpDir, { + d1: { binding: 'DB', databaseName: 'app', databaseId: 'real-uuid' }, + }), + ).toThrow(/malformed entry at index 0 \(missing a non-empty string "binding"\)/); + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('producer append fails closed on a mixed-malformed binding field rather than silently dropping (round-4 F6)', () => { + // A namespace field with a null member alongside a valid binding must reject the + // whole config when the worker-wide uniqueness collector reads it — never a + // partial read that could hide a collision. + setup( + 'wrangler.jsonc', + JSON.stringify({ d1_databases: [null, { binding: 'DB', database_id: 'id' }] }), + ); + const before = readRaw('wrangler.jsonc'); + expect(() => + writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'jobs', binding: 'QUEUE_JOBS' }] }, + }), + ).toThrow(/malformed entry at index 0 \(missing a non-empty string "binding"\)/); + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('collector fails closed on a malformed binding-list member (round-4 F6) — no write', () => { + // The `{ bindings: [...] }` shape (durable_objects/unsafe) is equally strict: a + // malformed member throws rather than falling through to a partial read. + setup( + 'wrangler.jsonc', + JSON.stringify({ + durable_objects: { bindings: [null, { name: 'DO', class_name: 'C' }] }, + }), + ); + const before = readRaw('wrangler.jsonc'); + expect(() => + writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'jobs', binding: 'QUEUE_JOBS' }] }, + }), + ).toThrow(/malformed entry at index 0 \(missing a non-empty string "name"\)/); + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + it('migrations_dir fails closed when the D1 binding is declared more than once (no findIndex-first)', () => { setup( 'wrangler.jsonc', From 03e8f821eebadd2fb68cf57025382c970459e38d Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 16 Jul 2026 17:54:40 +0800 Subject: [PATCH 021/128] fix(provision): close codex round-5 F1-F5 in the CF resource provisioner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F1 (critical): force the top-level wrangler config on EVERY invocation via an explicit empty `--env=` flag. wrangler 4.107 reloads a project `.env`/`.env.local` into process.env each command, so deleting CLOUDFLARE_ENV is not enough — a project `.env` with CLOUDFLARE_ENV=staging would reselect the env.staging block/account for discovery/create while --update-config patches top-level. `args.env ?? getCloudflareEnv()` short-circuits on the explicit empty args.env, wrangler's own documented top-level sentinel. One `wranglerConfigArgs` helper. F2: move the account fence to the provisioner boundary — require a resolvable effectiveAccountId whenever `desired` implies remote work, unconditionally (not gated on the default seam), persisted before selecting/calling any seam. F3: resolve rootWranglerPath to an ABSOLUTE path against `root` once, so Node reads/writes and the default wrangler (cwd: root) can never target different files. F4: verify queue creates with a follow-up `queues info` probe that POSITIVELY identifies the queue (queues write no config, so a masked signal-killed create — bin wrapper maps code===null to exit 0 — could otherwise record a false created). F5: collapse the two opposite-precedence D1 helpers into ONE strict normalizeD1(entry, requestedName) used by both list and info — exact requested-name match, reject conflicting name aliases, require one unambiguous id, reject multiple requested-name matches. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/cli/provision.ts | 297 ++++++++++----- packages/void/test/unit/provision.test.ts | 441 ++++++++++++++++++++-- 2 files changed, 603 insertions(+), 135 deletions(-) diff --git a/packages/void/src/cli/provision.ts b/packages/void/src/cli/provision.ts index a8e1115bf..ca0a40615 100644 --- a/packages/void/src/cli/provision.ts +++ b/packages/void/src/cli/provision.ts @@ -1,6 +1,7 @@ import { spawnSync } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; import { parse, type ParseError } from 'jsonc-parser'; +import { resolve } from 'pathe'; import { voidLog, voidWarn } from '../log.ts'; import { resolveWranglerArgv } from './preset.ts'; import { writeProvisionedWranglerConfig, type WriteProvisionedWranglerResult } from './wrangler.ts'; @@ -140,11 +141,16 @@ export function provisionEnv(accountId?: string): NodeJS.ProcessEnv { const env = { ...process.env }; delete env.CF_ACCESS_CLIENT_ID; delete env.CF_ACCESS_CLIENT_SECRET; - // Scrub CLOUDFLARE_ENV: it selects an ENVIRONMENT-specific config block, whose - // own `account_id` (and bindings) could differ from the single account this - // provision pinned — a footgun. Removing it forces wrangler onto the top-level - // (pinned) config/account so `wrangler list/create` cannot silently drift - // to an env-specific account we never resolved or persisted. + // Best-effort scrub of CLOUDFLARE_ENV (it selects an ENVIRONMENT-specific config + // block whose `account_id`/bindings could differ from the account this provision + // pinned). This ALONE is NOT sufficient: wrangler 4.107 reloads `.env`/`.env.local` + // from the project root into `process.env` on every command, so a project `.env` + // with `CLOUDFLARE_ENV=staging` would repopulate it after this delete. The RELIABLE + // top-level pin is the explicit empty `--env=` flag added to EVERY wrangler argv + // (see `wranglerConfigArgs`); wrangler resolves the env as `args.env ?? CLOUDFLARE_ENV`, + // and an explicit empty `args.env` short-circuits the `??` so the reloaded + // CLOUDFLARE_ENV can never reselect a named env-block/account. The delete stays as + // defense in depth for any other consumer of this env. delete env.CLOUDFLARE_ENV; // Pin the target account non-interactively (CI-safe) when known, so // `wrangler list/create` never prompts for disambiguation AND never falls @@ -533,43 +539,57 @@ function validateResourceList( } /** - * ONE strict D1 identity normalizer, shared by `d1 list --json` and - * `d1 info --json`. Requires EXACTLY ONE unambiguous NON-EMPTY id: - * - reads BOTH aliases (`database_id`, `uuid`), treating an EMPTY STRING as - * ABSENT for each — so an empty-string `uuid` can no longer shadow a valid - * `database_id` (`'' ?? x === ''` in the old `??` chain made a validated DB - * look absent → duplicate create); - * - prefers `database_id` when both are present and equal; - * - REJECTS conflicting non-empty aliases (two distinct ids → returns - * `undefined`, so the caller does not adopt an ambiguous identity). - * The `name` (from `database_name`/`name`, empty treated as absent) is returned so - * callers can require an EXACT requested-name match before adopting. + * The one D1 identity outcome, so a caller can distinguish "not the requested + * database" (skip → maybe create) from "IS the requested database but ambiguous" + * (fail closed → never adopt, never mistake for absent). */ -function normalizeD1Identity( - entry: Record | undefined, -): { id: string; name?: string } | undefined { - if (!entry) { - return undefined; +type D1Match = { kind: 'match'; id: string } | { kind: 'claimed-but-invalid' } | { kind: 'other' }; + +/** + * THE single strict D1 identity normalizer (F5), shared by `d1 list --json` and + * `d1 info --json`. It reconciles the TWO name aliases (`name`, + * `database_name`) and TWO id aliases (`database_id`, `uuid`) — an EMPTY STRING is + * treated as ABSENT for each — and returns a usable id ONLY when the entry + * unambiguously IS the requested database: + * - `{ kind: 'other' }` — no non-empty name alias equals `requestedName`, so this + * entry is a different database (the caller skips it, never mistakes it for the + * target, and never adopts it under a normalization of a DIFFERENT name); + * - `{ kind: 'claimed-but-invalid' }` — a name alias DOES equal `requestedName` + * but the entry is ambiguous: CONFLICTING non-empty name aliases (e.g. + * `name:"wanted"` + `database_name:"other"`), OR not EXACTLY ONE distinct + * non-empty id (0 → no id; >1 → conflicting/blank uuid vs database_id). The + * caller fails closed (never adopt, never create a duplicate); + * - `{ kind: 'match', id }` — exactly one requested-name alias and exactly one + * unambiguous non-empty id. + * The old two-helper split (`normalizeD1Identity` preferring `database_name`, + * `d1EntryName` preferring `name`) let a `{name,database_name}` mismatch be + * SELECTED under one alias yet KEYED under the other. This collapses both into one + * exact-match rule so no sibling gap remains. + */ +function normalizeD1(entry: Record | undefined, requestedName: string): D1Match { + if (!isRecord(entry)) { + return { kind: 'other' }; } - const ids = [entry.database_id, entry.uuid].filter( + const names = [entry.name, entry.database_name].filter( (value): value is string => typeof value === 'string' && value.length > 0, ); - // 0 distinct → no id; >1 distinct → conflicting/ambiguous. Either way, not a - // usable identity: the caller must fail closed rather than adopt/duplicate. - if (new Set(ids).size !== 1) { - return undefined; + // Not the requested database unless SOME non-empty alias equals it exactly. + if (!names.includes(requestedName)) { + return { kind: 'other' }; } - const name = [entry.database_name, entry.name].find( - (value): value is string => typeof value === 'string' && value.length > 0, - ); - return { id: ids[0], name }; -} - -/** The requested-name value carried by a `d1 list` member (either alias). */ -function d1EntryName(entry: Record): string | undefined { - return [entry.name, entry.database_name].find( + // It claims the requested name — now it must be UNAMBIGUOUS to adopt. + // Conflicting non-empty name aliases → ambiguous identity. + if (new Set(names).size > 1) { + return { kind: 'claimed-but-invalid' }; + } + const ids = [entry.database_id, entry.uuid].filter( (value): value is string => typeof value === 'string' && value.length > 0, ); + // 0 distinct → no id; >1 distinct → conflicting/blank aliases. Fail closed. + if (new Set(ids).size !== 1) { + return { kind: 'claimed-but-invalid' }; + } + return { kind: 'match', id: ids[0] }; } /** @@ -634,6 +654,27 @@ function isConfirmedAbsent(result: RunWranglerResult, kind: 'r2' | 'queue', name } } +/** + * F4 — a `wrangler queues info ` result POSITIVELY confirms THIS queue + * exists. Queues write NO config, so a create cannot be verified by re-reading the + * config (the D1/KV/R2/Hyperdrive backstop). And wrangler's `bin.wrangler` wrapper + * maps a signal-killed inner CLI to `process.exit(0)` (`bin/wrangler.js`: + * `code === null → exit(0)`), so a KILLED probe/create can surface a clean exit 0 + * with empty/partial output. A bare clean 0 is therefore NOT proof the queue exists. + * We require BOTH a clean success (numeric exit 0, no signal/spawn error — the + * provenance rule) AND the output to name THIS exact queue: wrangler 4.107's + * `queues info` prints `Queue Name: ` for the queue it fetched by name + * (account-unique), so an exact `Queue Name: ` line is the positive signal. + * Used for BOTH the reuse probe and the post-create verify probe. + */ +function queueInfoConfirmsExists(result: RunWranglerResult, name: string): boolean { + if (!isCleanSuccess(result)) { + return false; + } + const text = `${result.stdout}\n${result.stderr}`; + return new RegExp(`^Queue Name:\\s+${escapeRegExp(name)}\\s*$`, 'm').test(text); +} + /** * Throw when the writer could not attach `migrations_dir` because no matching * `d1_databases[]` entry exists. The provisioner only writes `migrations_dir` @@ -720,6 +761,26 @@ function assertValidResourceName(kind: string, name: string): void { } } +/** + * F1 — the trailing flags EVERY provisioner wrangler invocation MUST carry, built + * ONCE so no call site can forget them: + * - `-c ` pins the ONE resolved config (no split-brain reads/writes); + * - `--env=` (an explicit EMPTY environment) FORCES wrangler onto the top-level + * config environment. wrangler 4.107 selects its config env as + * `args.env ?? getCloudflareEnv()`; passing an explicit empty `args.env` + * short-circuits the `??` so a project `.env`/`.env.local` that sets + * `CLOUDFLARE_ENV` (reloaded into `process.env` on every wrangler command) + * can NEVER reselect a named `env.*` block — whose `account_id`/bindings could + * differ from the single account this provision resolved, patched, and pinned. + * `--env=` is wrangler's own documented sentinel for "use the top-level + * environment" (it prints exactly this guidance when multiple env blocks exist). + * Discovery/create AND `--update-config` therefore all operate on the SAME + * top-level config/account. + */ +function wranglerConfigArgs(configPath: string): Array { + return ['-c', configPath, '--env=']; +} + /** * Ensure every resource in `desired` exists in the user's own Cloudflare account, * creating the missing ones by shelling out to `wrangler` and letting @@ -743,9 +804,14 @@ export async function provisionCloudflareResources( }, ): Promise { await Promise.resolve(); - // ONE exact config path threaded through read-first, wrangler `-c`, AND the + // ONE ABSOLUTE config path threaded through read-first, wrangler `-c`, AND the // writer, so reads and writes can never hit different files (no split-brain). - const configPath = rootWranglerPath; + // Resolve it against `root` ONCE, up front: Node reads/writes here resolve a + // RELATIVE path against `process.cwd()`, but the default wrangler runs with + // `cwd: root` and resolves `-c` there — so with `root !== process.cwd()` a bare + // relative `rootWranglerPath` would let Node inspect one file while wrangler + // creates against another (orphan). An already-absolute path is returned as-is. + const configPath = resolve(root, rootWranglerPath); // --- F2: resource-name validation (BEFORE any argv / remote work) --------- // Every name that becomes a wrangler ARGV POSITIONAL is validated up front, so an @@ -810,14 +876,16 @@ export async function provisionCloudflareResources( !!desired.hyperdrive || desired.queues.length > 0; - // F1 fail-closed gate: the DEFAULT runWrangler passes the ambient - // CLOUDFLARE_ACCOUNT_ID to the REAL wrangler, so without a resolved account - // wrangler could create resources under an environment/ambient account we never - // persisted (a later retry under a different account would then rebind them). - // Require a known account before spawning it. The gate is scoped to the default - // path — the ONLY consumer of the ambient env; when a caller injects runWrangler - // (unit tests / a future orchestrator seam) it owns the account context. - if (!opts?.runWrangler && hasRemoteWork && !effectiveAccountId) { + // F2 — the MANDATORY account fence, at the PROVISIONER BOUNDARY (not inside the + // default runWrangler). Whenever `desired` implies remote work, an effective + // account MUST be resolvable — UNCONDITIONALLY, whether the wrangler runner and + // Hyperdrive lister are the default seams or injected. Otherwise a seam could do + // remote work (list/info/create/REST) under an ambient/interactive account we + // never persisted, so a partial run under account A followed by a retry under B + // would rebind A's resources with no reconciliation (the F1 pin needs a + // persisted `account_id` to fail closed on the retry). Fail closed BEFORE + // selecting or calling ANY seam. + if (hasRemoteWork && !effectiveAccountId) { throw new Error( `provision: no Cloudflare account is resolvable — set "account_id" in your root wrangler ` + `config or export CLOUDFLARE_ACCOUNT_ID before deploying. Refusing to provision resources ` + @@ -825,25 +893,31 @@ export async function provisionCloudflareResources( `account that is never persisted, so a later retry could rebind them elsewhere).`, ); } - const runWrangler = - opts?.runWrangler ?? createDefaultRunWrangler(root, { accountId: effectiveAccountId }); - // --- account_id (PERSIST FIRST, before any remote side effect) ----------- - // Pin the effective account_id NOW — before any create writes a real id — so a - // partial run can never leave freshly-minted ids in a config with NO account pin. - // Without this, a later retry under a DIFFERENT account would see the real id - // (read-first hit → skips discovery) and finally stamp its own `account_id`, - // binding the first account's resources under the second. With the pin written up - // front, a different-account retry instead hits the reconciliation above (config - // account ≠ requested → fail closed) before touching wrangler/REST. Skip the write - // when the config ALREADY pins that exact account (keeps byte-identical configs - // untouched). + // --- account_id (PERSIST FIRST, before selecting/calling any seam) -------- + // Pin the effective account_id NOW — before the runWrangler/lister seams are even + // selected, and before any create writes a real id — so a partial run can never + // leave freshly-minted ids in a config with NO account pin. Without this, a later + // retry under a DIFFERENT account would see the real id (read-first hit → skips + // discovery) and finally stamp its own `account_id`, binding the first account's + // resources under the second. With the pin written up front, a different-account + // retry instead hits the reconciliation above (config account ≠ requested → fail + // closed) before touching wrangler/REST. Skip the write when the config ALREADY + // pins that exact account (keeps byte-identical configs untouched). let accountIdWritten = false; if (effectiveAccountId && configAccountId !== effectiveAccountId) { write({ accountId: effectiveAccountId }); accountIdWritten = true; } + // Both seams are pinned to the SAME effective account: the default wrangler runner + // via its env (`CLOUDFLARE_ACCOUNT_ID` + the top-level `--env=` on every argv), and + // the default Hyperdrive lister via `createDefaultListHyperdriveConfigs` + // (below). An injected seam owns its own transport but only ever runs past the + // fence above, which guarantees a resolved + persisted account. + const runWrangler = + opts?.runWrangler ?? createDefaultRunWrangler(root, { accountId: effectiveAccountId }); + // --- D1 ------------------------------------------------------------------ if (desired.d1) { const { binding, name } = desired.d1; @@ -860,7 +934,7 @@ export async function provisionCloudflareResources( // Discovery must SUCCEED (gate) before its stdout is parsed, then the whole // list is strictly validated — a nonzero/SIGTERM/spawn-error or a malformed // member never parses as "absent" and mints a duplicate D1. - const listResult = runWrangler(['d1', 'list', '--json', '-c', configPath], { + const listResult = runWrangler(['d1', 'list', '--json', ...wranglerConfigArgs(configPath)], { captureJson: true, }); assertDiscoverySucceeded(listResult, 'wrangler d1 list'); @@ -878,20 +952,34 @@ export async function provisionCloudflareResources( nameField: 'name', nameLabel: 'named', }); - // F5: find the member whose name matches the request, then normalize its - // identity strictly. If the target-named member exists but its id is - // ambiguous/empty (e.g. an empty-string `uuid` that used to shadow a valid - // `database_id`, or conflicting aliases), FAIL CLOSED — never fall through to - // create (a duplicate) and never adopt an ambiguous id. - const namedMatch = databases.find((db) => d1EntryName(db) === name); - const existing = normalizeD1Identity(namedMatch); - if (namedMatch && !existing) { + // F5: run the ONE strict normalizer over EVERY member against the requested + // name. Collect the members that CLAIM the requested name (match or ambiguous). + // - >1 claimant → ambiguous which to adopt → fail closed; + // - 1 ambiguous claimant → fail closed (never adopt an ambiguous id, never + // mistake it for absent and mint a duplicate); + // - 1 valid claimant → adopt; 0 → create. + const claimants = databases + .map((db) => normalizeD1(db, name)) + .filter((m): m is Exclude => m.kind !== 'other'); + if (claimants.length > 1) { + throw new Error( + `provision: "wrangler d1 list" returned ${claimants.length} D1 databases named ` + + `${JSON.stringify(name)}. D1 database names are account-unique, so this is ambiguous; ` + + `cannot pick one to adopt and refusing to create a possible duplicate. Inspect the ` + + `account and retry.`, + ); + } + if (claimants.length === 1 && claimants[0].kind === 'claimed-but-invalid') { throw new Error( `provision: "wrangler d1 list" returned a D1 database named ${JSON.stringify(name)} with ` + - `an ambiguous or empty id (conflicting/blank uuid/database_id). Cannot adopt it, and ` + - `refusing to create a possible duplicate; inspect the account and retry.`, + `an ambiguous or empty id (conflicting/blank uuid/database_id, or conflicting name ` + + `aliases). Cannot adopt it, and refusing to create a possible duplicate; inspect the ` + + `account and retry.`, ); } + // After the guards above, a lone claimant is necessarily a valid `match`. + const existing = + claimants.length === 1 && claimants[0].kind === 'match' ? claimants[0] : undefined; if (existing) { log.info(`adopted existing D1 database ${name} (${existing.id})`); const res = write({ @@ -908,8 +996,7 @@ export async function provisionCloudflareResources( '--binding', binding, '--update-config', - '-c', - configPath, + ...wranglerConfigArgs(configPath), ]); if (isCleanSuccess(created)) { // wrangler wrote database_id/database_name via --update-config; VERIFY the @@ -933,12 +1020,15 @@ export async function provisionCloudflareResources( // TOCTOU: created between our list and create (clean non-zero, code 7502). // Capture the id via `d1 info` — which must SUCCEED (gate) and POSITIVELY // identify THIS database (EXACT name match) before we adopt its id. - const infoResult = runWrangler(['d1', 'info', name, '--json', '-c', configPath], { - captureJson: true, - }); + const infoResult = runWrangler( + ['d1', 'info', name, '--json', ...wranglerConfigArgs(configPath)], + { captureJson: true }, + ); assertDiscoverySucceeded(infoResult, `wrangler d1 info ${name}`); - const info = normalizeD1Identity(parseJsonObject(infoResult.stdout)); - if (!info || info.name !== name) { + // SAME strict normalizer as the list path — must POSITIVELY identify THIS + // database by an exact name match with an unambiguous id. + const info = normalizeD1(parseJsonObject(infoResult.stdout), name); + if (info.kind !== 'match') { throw new Error( `provision: "wrangler d1 create ${name}" reported the database already exists, ` + `but "wrangler d1 info ${name} --json" did not return a database with a matching ` + @@ -976,7 +1066,7 @@ export async function provisionCloudflareResources( // throw), return a real JSON array, and have EVERY member well-formed (a // malformed member — which could BE the target — fails closed rather than being // filtered to a false "absent" that mints a duplicate namespace). - const listResult = runWrangler(['kv', 'namespace', 'list', '-c', configPath], { + const listResult = runWrangler(['kv', 'namespace', 'list', ...wranglerConfigArgs(configPath)], { captureJson: true, }); assertDiscoverySucceeded(listResult, 'wrangler kv namespace list'); @@ -1018,8 +1108,7 @@ export async function provisionCloudflareResources( '--binding', binding, '--update-config', - '-c', - configPath, + ...wranglerConfigArgs(configPath), ]); if (!isCleanSuccess(created)) { throw new Error( @@ -1052,9 +1141,10 @@ export async function provisionCloudflareResources( } else { // `wrangler r2 bucket list` is a human table, not JSON. Bucket names ARE // account-unique, so probe the single bucket by name. - const info = runWrangler(['r2', 'bucket', 'info', bucketName, '--json', '-c', configPath], { - captureJson: true, - }); + const info = runWrangler( + ['r2', 'bucket', 'info', bucketName, '--json', ...wranglerConfigArgs(configPath)], + { captureJson: true }, + ); if (isCleanSuccess(info)) { // A clean exit 0 is not enough to adopt — the success payload must // POSITIVELY identify the requested bucket (its `name`) before we write the @@ -1083,8 +1173,7 @@ export async function provisionCloudflareResources( '--binding', binding, '--update-config', - '-c', - configPath, + ...wranglerConfigArgs(configPath), ]); if (!isCleanSuccess(created)) { throw new Error( @@ -1192,8 +1281,7 @@ export async function provisionCloudflareResources( '--binding', binding, '--update-config', - '-c', - configPath, + ...wranglerConfigArgs(configPath), ]); if (!isCleanSuccess(created)) { throw new Error( @@ -1224,29 +1312,46 @@ export async function provisionCloudflareResources( // queue RESOURCE exists — binding the producers/consumers into the config is // app-class-aware and belongs to Task 6. for (const name of desired.queues) { - const info = runWrangler(['queues', 'info', name, '-c', configPath]); - if (isCleanSuccess(info)) { + const info = runWrangler(['queues', 'info', name, ...wranglerConfigArgs(configPath)]); + // "Exists → reuse" ONLY when the probe POSITIVELY names THIS queue (F4). A bare + // clean 0 is NOT trusted: the bin wrapper can mask a signal-killed inner CLI as + // exit 0 with empty output, which a plain `isCleanSuccess` would misread as + // reused though nothing was verified. + if (queueInfoConfirmsExists(info, name)) { resources.push({ kind: 'queue', name, action: 'reused' }); continue; } - // A probe is "absent" (→ create) ONLY on a CLEAN non-zero exit carrying the - // EXACT, name-aware not-found signal (`Queue "" does not exist`). Any - // signal/spawn failure (HARD failure), an auth error, or a generic `Account not - // found` is NOT absence and fails closed (never a blind create). + // Not confirmed-present. It is "absent" (→ create) ONLY on a CLEAN non-zero exit + // carrying the EXACT, name-aware not-found signal (`Queue "" does not + // exist`). Everything else — a signal/spawn failure, an auth error, a generic + // `Account not found`, OR a masked-kill clean-0 that did NOT positively identify + // the queue — fails closed (never a blind create, never a false reuse). if (!isConfirmedAbsent(info, 'queue', name)) { throw new Error( - `provision: "wrangler queues info ${name}" failed: ${info.stderr || info.stdout}`, + `provision: "wrangler queues info ${name}" failed or did not confirm the queue exists: ` + + `${info.stderr || info.stdout || 'no output'}`, ); } - const created = runWrangler(['queues', 'create', name, '-c', configPath]); - // Queue creates write NO config, so they cannot be postcondition-verified; a - // killed queue-create self-heals on the next run (its `queues info` probe - // re-detects absence and re-creates). We still fail closed on a non-clean exit. + const created = runWrangler(['queues', 'create', name, ...wranglerConfigArgs(configPath)]); if (!isCleanSuccess(created)) { throw new Error( `provision: "wrangler queues create ${name}" failed: ${created.stderr || created.stdout}`, ); } + // F4 verify probe — queues write NO config, so `assertCreateWroteRealId` cannot + // cover them. A create reporting clean 0 is NOT enough (the bin wrapper masks a + // signal-killed inner CLI as exit 0, creating nothing). RE-RUN the SAME positive + // `queues info` probe and only record `created` if it now confirms THIS queue; + // otherwise fail closed (a killed create self-heals on the next run's probe). + const verify = runWrangler(['queues', 'info', name, ...wranglerConfigArgs(configPath)]); + if (!queueInfoConfirmsExists(verify, name)) { + throw new Error( + `provision: "wrangler queues create ${name}" reported success but "wrangler queues info ` + + `${name}" did not confirm the queue exists afterward. The create may have been ` + + `interrupted — wrangler's launcher can mask a signal-killed child as exit 0. Refusing to ` + + `record an unprovisioned queue; inspect the account and retry.`, + ); + } resources.push({ kind: 'queue', name, action: 'created' }); } diff --git a/packages/void/test/unit/provision.test.ts b/packages/void/test/unit/provision.test.ts index 3198410dd..314c0d0d0 100644 --- a/packages/void/test/unit/provision.test.ts +++ b/packages/void/test/unit/provision.test.ts @@ -1,7 +1,7 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { parse as parseJsoncRaw } from 'jsonc-parser'; import { join } from 'pathe'; -import { afterEach, describe, expect, it } from 'vite-plus/test'; +import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'; import { normalizeRunWranglerResult, provisionCloudflareResources, @@ -20,8 +20,30 @@ function setup(content: string): void { writeFileSync(wranglerPath, content); } +/** + * F2 — the provisioner requires a resolvable Cloudflare account whenever `desired` + * implies remote work (the boundary account fence), regardless of injected vs + * default seams. Real deploys always have one (`CLOUDFLARE_ACCOUNT_ID` or a config + * `account_id`); mirror that by pinning an ambient account for every test, so the + * fence is satisfied without threading `accountId` through each call. Tests that + * exercise the NO-account / reconciliation / different-account paths delete or + * override it locally (and restore in their own finally / via `afterEach`). + */ +const AMBIENT_ACCOUNT = 'ambient-test-account'; +let savedAmbientAccount: string | undefined; + +beforeEach(() => { + savedAmbientAccount = process.env.CLOUDFLARE_ACCOUNT_ID; + process.env.CLOUDFLARE_ACCOUNT_ID = AMBIENT_ACCOUNT; +}); + afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); + if (savedAmbientAccount === undefined) { + delete process.env.CLOUDFLARE_ACCOUNT_ID; + } else { + process.env.CLOUDFLARE_ACCOUNT_ID = savedAmbientAccount; + } }); function readConfig(): Record { @@ -128,7 +150,7 @@ describe('provisionCloudflareResources — read-first', () => { { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, { runWrangler: run, log: silentLog }, ); - expect(calls[0]).toEqual(['d1', 'list', '--json', '-c', wranglerPath]); + expect(calls[0]).toEqual(['d1', 'list', '--json', '-c', wranglerPath, '--env=']); expect(result.resources[0].action).toBe('created'); }); }); @@ -167,6 +189,7 @@ describe('provisionCloudflareResources — argv injection safety', () => { '--update-config', '-c', wranglerPath, + '--env=', ]); // The whole connection string (with & $ ; and a space) is a SINGLE element. expect(createCall!.filter((t) => t.includes('DROP TABLE t'))).toHaveLength(1); @@ -212,6 +235,7 @@ describe('provisionCloudflareResources — argv injection safety', () => { '--update-config', '-c', spacedPath, + '--env=', ]); expect(result.resources[0].action).toBe('created'); } finally { @@ -312,8 +336,18 @@ describe('provisionCloudflareResources — D1', () => { { runWrangler: run, log: silentLog }, ); expect(calls).toEqual([ - ['d1', 'list', '--json', '-c', wranglerPath], - ['d1', 'create', 'myapp-db', '--binding', 'DB', '--update-config', '-c', wranglerPath], + ['d1', 'list', '--json', '-c', wranglerPath, '--env='], + [ + 'd1', + 'create', + 'myapp-db', + '--binding', + 'DB', + '--update-config', + '-c', + wranglerPath, + '--env=', + ], ]); const d1 = readConfig().d1_databases as Array>; expect(d1).toEqual([ @@ -377,7 +411,7 @@ describe('provisionCloudflareResources — D1', () => { { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, { runWrangler: run, log: silentLog }, ); - expect(calls).toEqual([['d1', 'list', '--json', '-c', wranglerPath]]); + expect(calls).toEqual([['d1', 'list', '--json', '-c', wranglerPath, '--env=']]); expect(calls.some((c) => isCall(c, 'd1', 'create'))).toBe(false); const d1 = readConfig().d1_databases as Array>; expect(d1).toEqual([ @@ -417,9 +451,19 @@ describe('provisionCloudflareResources — D1', () => { { runWrangler: run, log: silentLog }, ); expect(calls).toEqual([ - ['d1', 'list', '--json', '-c', wranglerPath], - ['d1', 'create', 'myapp-db', '--binding', 'DB', '--update-config', '-c', wranglerPath], - ['d1', 'info', 'myapp-db', '--json', '-c', wranglerPath], + ['d1', 'list', '--json', '-c', wranglerPath, '--env='], + [ + 'd1', + 'create', + 'myapp-db', + '--binding', + 'DB', + '--update-config', + '-c', + wranglerPath, + '--env=', + ], + ['d1', 'info', 'myapp-db', '--json', '-c', wranglerPath, '--env='], ]); const d1 = readConfig().d1_databases as Array>; expect(d1[0]).toMatchObject({ binding: 'DB', database_id: 'raced-uuid' }); @@ -576,7 +620,7 @@ describe('provisionCloudflareResources — KV', () => { { ...emptyDesired, kvNamespaces: [{ binding: 'KV', title: 'myapp-kv' }] }, { runWrangler: run, log: silentLog }, ); - expect(calls).toEqual([['kv', 'namespace', 'list', '-c', wranglerPath]]); + expect(calls).toEqual([['kv', 'namespace', 'list', '-c', wranglerPath, '--env=']]); const kv = readConfig().kv_namespaces as Array>; expect(kv).toEqual([{ binding: 'KV', id: 'real-kv-id' }]); expect(result.resources).toEqual([ @@ -603,7 +647,7 @@ describe('provisionCloudflareResources — KV', () => { { runWrangler: run, log: silentLog }, ); expect(calls).toEqual([ - ['kv', 'namespace', 'list', '-c', wranglerPath], + ['kv', 'namespace', 'list', '-c', wranglerPath, '--env='], [ 'kv', 'namespace', @@ -614,6 +658,7 @@ describe('provisionCloudflareResources — KV', () => { '--update-config', '-c', wranglerPath, + '--env=', ], ]); expect(result.resources).toEqual([ @@ -746,15 +791,27 @@ describe('provisionCloudflareResources — account_id', () => { expect(result.accountIdWritten).toBe(true); }); - it('does not write account_id when not provided', async () => { + it('does not write account_id when not provided (and none resolvable)', async () => { setup(JSON.stringify({ name: 'app' })); - const { run } = makeRunner([]); - const result = await provisionCloudflareResources(tmpDir, wranglerPath, emptyDesired, { - runWrangler: run, - log: silentLog, - }); - expect(readConfig().account_id).toBeUndefined(); - expect(result.accountIdWritten).toBe(false); + // Truly no account anywhere: clear the ambient the harness pins. emptyDesired + // implies NO remote work, so the fence does not fire and nothing is persisted. + const prev = process.env.CLOUDFLARE_ACCOUNT_ID; + delete process.env.CLOUDFLARE_ACCOUNT_ID; + try { + const { run } = makeRunner([]); + const result = await provisionCloudflareResources(tmpDir, wranglerPath, emptyDesired, { + runWrangler: run, + log: silentLog, + }); + expect(readConfig().account_id).toBeUndefined(); + expect(result.accountIdWritten).toBe(false); + } finally { + if (prev === undefined) { + delete process.env.CLOUDFLARE_ACCOUNT_ID; + } else { + process.env.CLOUDFLARE_ACCOUNT_ID = prev; + } + } }); }); @@ -821,7 +878,7 @@ describe('provisionCloudflareResources — R2', () => { { runWrangler: run, log: silentLog }, ); expect(calls).toEqual([ - ['r2', 'bucket', 'info', 'myapp-uploads', '--json', '-c', wranglerPath], + ['r2', 'bucket', 'info', 'myapp-uploads', '--json', '-c', wranglerPath, '--env='], ]); expect(readConfig().r2_buckets).toEqual([{ binding: 'STORAGE', bucket_name: 'myapp-uploads' }]); expect(result.resources).toEqual([ @@ -854,7 +911,7 @@ describe('provisionCloudflareResources — R2', () => { { runWrangler: run, log: silentLog }, ); expect(calls).toEqual([ - ['r2', 'bucket', 'info', 'myapp-uploads', '--json', '-c', wranglerPath], + ['r2', 'bucket', 'info', 'myapp-uploads', '--json', '-c', wranglerPath, '--env='], [ 'r2', 'bucket', @@ -865,6 +922,7 @@ describe('provisionCloudflareResources — R2', () => { '--update-config', '-c', wranglerPath, + '--env=', ], ]); expect(result.resources).toEqual([ @@ -919,7 +977,7 @@ describe('provisionCloudflareResources — R2', () => { ).rejects.toThrow(/r2 bucket info myapp-uploads.*(failed|refusing)/s); // No create attempted (the existing bucket must not be duplicated/errored on). expect(calls).toEqual([ - ['r2', 'bucket', 'info', 'myapp-uploads', '--json', '-c', wranglerPath], + ['r2', 'bucket', 'info', 'myapp-uploads', '--json', '-c', wranglerPath, '--env='], ]); }); }); @@ -1018,6 +1076,7 @@ describe('provisionCloudflareResources — Hyperdrive', () => { '--update-config', '-c', wranglerPath, + '--env=', ], ]); expect(result.resources).toEqual([ @@ -1091,20 +1150,31 @@ describe('provisionCloudflareResources — Queues (resource only)', () => { { ...emptyDesired, queues: ['jobs'] }, { runWrangler: run, log: silentLog }, ); - expect(calls).toEqual([['queues', 'info', 'jobs', '-c', wranglerPath]]); + expect(calls).toEqual([['queues', 'info', 'jobs', '-c', wranglerPath, '--env=']]); expect(result.resources).toEqual([{ kind: 'queue', name: 'jobs', action: 'reused' }]); // Task 4 must NOT write queue producer/consumer entries into the config. expect(readConfig().queues).toBeUndefined(); }); - it('creates a queue that `queues info` reports missing; never writes queue config entries', async () => { + it('creates a queue that `queues info` reports missing; verifies with a follow-up info probe (F4)', async () => { setup(JSON.stringify({ name: 'app' })); + // F4: after `queues create`, the provisioner RE-RUNS `queues info` and only + // records `created` if it now POSITIVELY confirms the queue (queues write no + // config, so this is the only backstop against a masked-signal create). + let created = false; const { run, calls } = makeRunner([ { match: /^queues info emails/, - reply: () => fail('', 'X [ERROR] Queue "emails" does not exist.'), + reply: () => + created ? ok('Queue Name: emails') : fail('', 'X [ERROR] Queue "emails" does not exist.'), + }, + { + match: /^queues create emails/, + reply: () => { + created = true; + return ok('Created queue emails'); + }, }, - { match: /^queues create emails/, reply: () => ok('Created queue emails') }, ]); const result = await provisionCloudflareResources( tmpDir, @@ -1113,8 +1183,9 @@ describe('provisionCloudflareResources — Queues (resource only)', () => { { runWrangler: run, log: silentLog }, ); expect(calls).toEqual([ - ['queues', 'info', 'emails', '-c', wranglerPath], - ['queues', 'create', 'emails', '-c', wranglerPath], + ['queues', 'info', 'emails', '-c', wranglerPath, '--env='], + ['queues', 'create', 'emails', '-c', wranglerPath, '--env='], + ['queues', 'info', 'emails', '-c', wranglerPath, '--env='], ]); expect(result.resources).toEqual([{ kind: 'queue', name: 'emails', action: 'created' }]); expect(readConfig().queues).toBeUndefined(); @@ -1122,10 +1193,21 @@ describe('provisionCloudflareResources — Queues (resource only)', () => { it('handles a mix of existing and missing queues', async () => { setup(JSON.stringify({ name: 'app' })); + let created = false; const { run, calls } = makeRunner([ { match: /^queues info jobs/, reply: () => ok('Queue Name: jobs') }, - { match: /^queues info emails/, reply: () => fail('', 'Queue "emails" does not exist.') }, - { match: /^queues create emails/, reply: () => ok() }, + { + match: /^queues info emails/, + reply: () => + created ? ok('Queue Name: emails') : fail('', 'Queue "emails" does not exist.'), + }, + { + match: /^queues create emails/, + reply: () => { + created = true; + return ok(); + }, + }, ]); const result = await provisionCloudflareResources( tmpDir, @@ -1134,9 +1216,10 @@ describe('provisionCloudflareResources — Queues (resource only)', () => { { runWrangler: run, log: silentLog }, ); expect(calls).toEqual([ - ['queues', 'info', 'jobs', '-c', wranglerPath], - ['queues', 'info', 'emails', '-c', wranglerPath], - ['queues', 'create', 'emails', '-c', wranglerPath], + ['queues', 'info', 'jobs', '-c', wranglerPath, '--env='], + ['queues', 'info', 'emails', '-c', wranglerPath, '--env='], + ['queues', 'create', 'emails', '-c', wranglerPath, '--env='], + ['queues', 'info', 'emails', '-c', wranglerPath, '--env='], ]); expect(result.resources).toEqual([ { kind: 'queue', name: 'jobs', action: 'reused' }, @@ -1205,12 +1288,20 @@ describe('provisionCloudflareResources — Queues (resource only)', () => { it('creates ONLY on the exact `Queue "" does not exist` signal for THAT name (F5)', async () => { setup(JSON.stringify({ name: 'app' })); + let created = false; const { run, calls } = makeRunner([ { match: /^queues info jobs/, - reply: () => fail('', 'X [ERROR] Queue "jobs" does not exist.'), + reply: () => + created ? ok('Queue Name: jobs') : fail('', 'X [ERROR] Queue "jobs" does not exist.'), + }, + { + match: /^queues create jobs/, + reply: () => { + created = true; + return ok('Created queue jobs'); + }, }, - { match: /^queues create jobs/, reply: () => ok('Created queue jobs') }, ]); const result = await provisionCloudflareResources( tmpDir, @@ -1219,11 +1310,44 @@ describe('provisionCloudflareResources — Queues (resource only)', () => { { runWrangler: run, log: silentLog }, ); expect(calls).toEqual([ - ['queues', 'info', 'jobs', '-c', wranglerPath], - ['queues', 'create', 'jobs', '-c', wranglerPath], + ['queues', 'info', 'jobs', '-c', wranglerPath, '--env='], + ['queues', 'create', 'jobs', '-c', wranglerPath, '--env='], + ['queues', 'info', 'jobs', '-c', wranglerPath, '--env='], ]); expect(result.resources).toEqual([{ kind: 'queue', name: 'jobs', action: 'created' }]); }); + + it('fails closed when a masked-signal `queues create` (wrapper exit 0) did NOT create the queue (F4)', async () => { + // wrangler's bin wrapper maps a signal-killed inner CLI to exit 0. Queues write + // no config, so the ONLY backstop is the post-create verify probe: a create that + // reports clean 0 but whose follow-up `queues info` still does not confirm the + // queue must fail closed (never a false `created`). + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { + // Both the initial probe AND the verify probe report the queue absent — the + // masked-kill create never actually created it. + match: /^queues info jobs/, + reply: () => fail('', 'X [ERROR] Queue "jobs" does not exist.'), + }, + // A signal-killed inner CLI surfaced as a clean exit 0 by the wrapper. + { match: /^queues create jobs/, reply: () => ok('') }, + ]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, queues: ['jobs'] }, + { runWrangler: run, log: silentLog }, + ), + ).rejects.toThrow(/did not confirm the queue exists afterward/); + // The verify probe ran (info, create, info) but the queue was never recorded. + expect(calls).toEqual([ + ['queues', 'info', 'jobs', '-c', wranglerPath, '--env='], + ['queues', 'create', 'jobs', '-c', wranglerPath, '--env='], + ['queues', 'info', 'jobs', '-c', wranglerPath, '--env='], + ]); + }); }); describe('provisionCloudflareResources — account reconciliation + config pin (F1)', () => { @@ -1315,6 +1439,7 @@ describe('provisionCloudflareResources — account reconciliation + config pin ( it('passes -c to EVERY wrangler invocation (list, info, create)', async () => { setup(JSON.stringify({ name: 'app' })); + let queueCreated = false; const { run, calls } = makeRunner([ { match: /^d1 list --json/, reply: () => ok('[]') }, { @@ -1344,8 +1469,18 @@ describe('provisionCloudflareResources — account reconciliation + config pin ( return ok(); }, }, - { match: /^queues info /, reply: () => fail('', 'Queue "jobs" does not exist.') }, - { match: /^queues create /, reply: () => ok() }, + { + match: /^queues info /, + reply: () => + queueCreated ? ok('Queue Name: jobs') : fail('', 'Queue "jobs" does not exist.'), + }, + { + match: /^queues create /, + reply: () => { + queueCreated = true; + return ok(); + }, + }, ]); await provisionCloudflareResources( tmpDir, @@ -1359,12 +1494,14 @@ describe('provisionCloudflareResources — account reconciliation + config pin ( }, { runWrangler: run, log: silentLog }, ); - // Every wrangler argv threads the ONE resolved config via `-c `. + // Every wrangler argv threads the ONE resolved config via `-c ` AND + // the top-level `--env=` pin (F1). expect(calls.length).toBeGreaterThan(0); for (const argv of calls) { const i = argv.indexOf('-c'); expect(i).toBeGreaterThanOrEqual(0); expect(argv[i + 1]).toBe(wranglerPath); + expect(argv[i + 2]).toBe('--env='); } }); @@ -2109,3 +2246,229 @@ describe('provisionCloudflareResources — F5 strict D1 identity (round 4)', () expect(readConfig().d1_databases).toBeUndefined(); }); }); + +describe('provisionCloudflareResources — F1 top-level env pin via --env= (round 5)', () => { + it('pins the top-level env on EVERY wrangler call even when CLOUDFLARE_ENV names a different env/account', async () => { + // wrangler 4.107 reloads a project `.env`/`.env.local` into process.env on every + // command, so a project `.env` with `CLOUDFLARE_ENV=staging` would reselect the + // `env.staging` block (and ITS account) for discovery/create even though + // `provisionEnv` deleted the var — while `--update-config` patches the TOP-LEVEL + // config, a cross-account/config split. The reliable pin is the explicit empty + // `--env=` on every argv: wrangler resolves `args.env ?? getCloudflareEnv()`, and + // an explicit empty `args.env` short-circuits the `??` so the reloaded + // CLOUDFLARE_ENV can never select a named env. Assert every call carries it and + // the pinned account stays the top-level one. + setup(JSON.stringify({ account_id: 'top-level-acct', name: 'app' })); + const prevEnv = process.env.CLOUDFLARE_ENV; + process.env.CLOUDFLARE_ENV = 'staging'; // as if repopulated from a project .env + try { + let created = false; + const { run, calls } = makeRunner([ + { match: /^d1 list --json/, reply: () => ok('[]') }, + { + match: /^d1 create /, + reply: () => { + simulateUpdateConfig('d1_databases', { + binding: 'DB', + database_name: 'myapp-db', + database_id: 'created-uuid', + }); + return ok(); + }, + }, + { + match: /^queues info jobs/, + reply: () => + created ? ok('Queue Name: jobs') : fail('', 'Queue "jobs" does not exist.'), + }, + { + match: /^queues create jobs/, + reply: () => { + created = true; + return ok(); + }, + }, + ]); + await provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' }, queues: ['jobs'] }, + { runWrangler: run, log: silentLog }, + ); + // EVERY invocation forces top-level via the explicit empty `--env=` token, so + // discovery/create cannot drift to the staging env's account. + expect(calls.length).toBeGreaterThan(0); + for (const argv of calls) { + expect(argv).toContain('--env='); + } + // No cross-account drift: the top-level account remains pinned. + expect(readConfig().account_id).toBe('top-level-acct'); + } finally { + if (prevEnv === undefined) { + delete process.env.CLOUDFLARE_ENV; + } else { + process.env.CLOUDFLARE_ENV = prevEnv; + } + } + }); +}); + +describe('provisionCloudflareResources — F2 boundary account fence (round 5)', () => { + it('fails closed BEFORE any INJECTED seam call when no account is resolvable', async () => { + // The prior fence was gated on `!opts.runWrangler`, so an INJECTED runWrangler / + // Hyperdrive lister did remote work with no effective (persisted) account — a + // partial run under one account, retry under another with no reconciliation. The + // boundary fence now fires unconditionally. + setup(JSON.stringify({ name: 'app' })); // no account_id + const prev = process.env.CLOUDFLARE_ACCOUNT_ID; + delete process.env.CLOUDFLARE_ACCOUNT_ID; // no ambient account either + try { + let restCalled = false; + const { run, calls } = makeRunner([{ match: /.*/, reply: () => ok() }]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + hyperdrive: { + binding: 'HYPERDRIVE', + name: 'myapp-hd', + connectionString: 'postgres://x', + }, + }, + { + runWrangler: run, // INJECTED — old fence skipped this path + log: silentLog, + listHyperdriveConfigs: async () => { + restCalled = true; + return []; + }, + }, + ), + ).rejects.toThrow(/no Cloudflare account is resolvable/); + // No wrangler call, no REST call, and nothing persisted — fence is at the boundary. + expect(calls).toEqual([]); + expect(restCalled).toBe(false); + expect(readConfig().account_id).toBeUndefined(); + } finally { + if (prev === undefined) { + delete process.env.CLOUDFLARE_ACCOUNT_ID; + } else { + process.env.CLOUDFLARE_ACCOUNT_ID = prev; + } + } + }); +}); + +describe('provisionCloudflareResources — F3 absolute config path (round 5)', () => { + it('resolves a RELATIVE rootWranglerPath against root (not cwd) so reads + wrangler ops target ONE file', async () => { + // root !== process.cwd(): a bare relative config path would make Node reads/writes + // resolve against cwd while the default wrangler (cwd: root) resolves `-c` against + // root — a split (inspect one file, create against another). The provisioner + // resolves the path to an ABSOLUTE one against `root` ONCE, up front. + setup(JSON.stringify({ account_id: 'acct', name: 'app' })); // at tmpDir/wrangler.jsonc + const relPath = 'wrangler.jsonc'; // relative to root (tmpDir), NOT to process.cwd() + const abs = join(tmpDir, relPath); + const { run, calls } = makeRunner([ + { + match: /^kv namespace list/, + reply: () => ok(JSON.stringify([{ id: 'real-kv-id', title: 'myapp-kv' }])), + }, + ]); + const result = await provisionCloudflareResources( + tmpDir, // root — absolute, and different from process.cwd() + relPath, // RELATIVE config path + { ...emptyDesired, kvNamespaces: [{ binding: 'KV', title: 'myapp-kv' }] }, + { runWrangler: run, log: silentLog }, + ); + // Returned + threaded config path is the absolute resolution against root. + expect(result.configPath).toBe(abs); + expect(calls.length).toBeGreaterThan(0); + for (const argv of calls) { + const i = argv.indexOf('-c'); + expect(i).toBeGreaterThanOrEqual(0); + expect(argv[i + 1]).toBe(abs); // wrangler `-c` is the SAME absolute file + } + // The adopted id landed in root/wrangler.jsonc — the file Node read from too. + const after = parseJsoncRaw(readFileSync(abs, 'utf-8')) as Record; + expect((after.kv_namespaces as Array>)[0].id).toBe('real-kv-id'); + }); +}); + +describe('provisionCloudflareResources — F5 one D1 normalizer (round 5)', () => { + it('fails closed on CONFLICTING name aliases (name vs database_name) for the requested database', async () => { + // The exact F5 scenario: a list member claims the requested name via `name` but its + // `database_name` disagrees. The old two-helper split SELECTED it by `name` yet + // normalized its identity under `database_name` — adopting a WRONG identity. The one + // normalizer rejects the conflict (fail closed, no adopt/create). + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { + match: /^d1 list --json/, + reply: () => + ok( + JSON.stringify([ + { name: 'myapp-db', database_name: 'other-db', database_id: 'the-id' }, + ]), + ), + }, + ]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, + { runWrangler: run, log: silentLog }, + ), + ).rejects.toThrow(/ambiguous or empty id/); + expect(calls.some((c) => isCall(c, 'd1', 'create'))).toBe(false); + expect(readConfig().d1_databases).toBeUndefined(); + }); + + it('fails closed when TWO list entries both match the requested name (ambiguous)', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { + match: /^d1 list --json/, + reply: () => + ok( + JSON.stringify([ + { name: 'myapp-db', database_id: 'id-1' }, + { name: 'myapp-db', database_id: 'id-2' }, + ]), + ), + }, + ]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, + { runWrangler: run, log: silentLog }, + ), + ).rejects.toThrow(/2 D1 databases named/); + expect(calls.some((c) => isCall(c, 'd1', 'create'))).toBe(false); + }); + + it('adopts the single unambiguous match (agreeing aliases + uuid) — no create', async () => { + setup(JSON.stringify({ name: 'app' })); + const { run, calls } = makeRunner([ + { + match: /^d1 list --json/, + reply: () => + ok(JSON.stringify([{ name: 'myapp-db', database_name: 'myapp-db', uuid: 'the-id' }])), + }, + ]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, + { runWrangler: run, log: silentLog }, + ); + expect(calls.some((c) => isCall(c, 'd1', 'create'))).toBe(false); + expect(result.resources[0]).toMatchObject({ action: 'adopted', id: 'the-id' }); + const d1 = readConfig().d1_databases as Array>; + expect(d1[0].database_id).toBe('the-id'); + }); +}); From eccb1cad925d8b9355ba8c508dfe91af70f4ea18 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 16 Jul 2026 18:05:46 +0800 Subject: [PATCH 022/128] =?UTF-8?q?fix(wrangler):=20close=20codex=20round-?= =?UTF-8?q?5=20F6=20=E2=80=94=20comprehensive=20strict=20binding-shape=20v?= =?UTF-8?q?alidator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The binding-name collision collector accepted malformed binding-bearing members in several sibling shapes: a `{bindings:[null]}` (all members malformed) fell through the `some(isRecord)` guard as a plain map; a singleton record with a non-string `binding` (ai/browser/…) yielded an empty set; `secrets.required`, queue producers, and adopt target arrays silently skipped malformed members. Each fail-open could let the worker-wide uniqueness guard miss a real binding and commit a conflicting one. Collapse to ONE field-aware strict reader: bindingNamesForField routes each field to its known wrangler config shape — binding LISTS (durable_objects/unsafe/logfwdr), namespace arrays (kv/d1/r2/…), SINGLETON objects (ai/browser/images/version_metadata/ assets), NAME-keyed arrays (send_email/ratelimits) — throwing on any malformed/empty binding identity instead of dropping it. secrets.required and queues.producers are validated strictly in the collector, and the adopt path now strictly validates its TARGET field too (the owner map excludes it). Field-awareness avoids misreading a key-map field (e.g. a `vars` entry keyed `bindings`/`binding`). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/cli/wrangler.ts | 140 ++++++++++++++++++++--- packages/void/test/unit/wrangler.test.ts | 74 ++++++++++++ 2 files changed, 197 insertions(+), 17 deletions(-) diff --git a/packages/void/src/cli/wrangler.ts b/packages/void/src/cli/wrangler.ts index c38db5983..b759ff634 100644 --- a/packages/void/src/cli/wrangler.ts +++ b/packages/void/src/cli/wrangler.ts @@ -931,6 +931,13 @@ export function writeProvisionedWranglerConfig( // adopt the same binding into two arrays without noticing the collision. const currentConfig = parseJsonc(text); const rawList = currentConfig[field]; + // F6 — strictly validate the TARGET field's own shape too. The owner collector + // below EXCLUDES this field (so the matched entry does not self-collide), so + // without this a malformed member in the target array (e.g. a `null` beside a + // valid `{binding}`) would be silently skipped by the match loop and could hide a + // real duplicate binding. Reuse the ONE field-aware validator; it throws on any + // malformed member (fail closed) before we mutate the array. + bindingNamesForField(field, rawList); const currentList = Array.isArray(rawList) ? [...rawList] : []; const matches: Array = []; for (let i = 0; i < currentList.length; i++) { @@ -1628,12 +1635,28 @@ function collectWorkerBindingNames(config: Record): Map { + if (typeof name !== 'string' || name.length === 0) { + throw new Error( + `deploy: cannot verify binding-name uniqueness — "secrets.required" contains a malformed ` + + `entry at index ${index} (expected a non-empty string). Refusing to proceed on a ` + + `partially-readable binding set (a dropped entry could hide a real collision). Fix the ` + + `wrangler config and retry.`, + ); + } + claim(name, 'Secret'); + }); } return owners; } @@ -1661,18 +1684,35 @@ function addQueueProducerOwners( config: Record, ): void { const queues = config.queues; - if (!isRecord(queues) || !Array.isArray(queues.producers)) { + if (!isRecord(queues) || queues.producers === undefined) { return; } - for (const producer of queues.producers) { + // F6 — validate `queues.producers` STRICTLY: a non-array, or any member missing a + // non-empty string `binding`, throws (fail closed) rather than being silently + // skipped, which would let the adopt guard miss a real producer-binding collision. + if (!Array.isArray(queues.producers)) { + throw new Error( + `deploy: cannot verify binding-name uniqueness — "queues.producers" must be an array. Fix ` + + `the wrangler config and retry.`, + ); + } + queues.producers.forEach((producer, index) => { if ( - isRecord(producer) && - typeof producer.binding === 'string' && - !owners.has(producer.binding) + !isRecord(producer) || + typeof producer.binding !== 'string' || + producer.binding.length === 0 ) { + throw new Error( + `deploy: cannot verify binding-name uniqueness — "queues.producers" contains a malformed ` + + `entry at index ${index} (missing a non-empty string "binding"). Refusing to proceed on a ` + + `partially-readable binding set (a dropped producer could hide a real collision). Fix the ` + + `wrangler config and retry.`, + ); + } + if (!owners.has(producer.binding)) { owners.set(producer.binding, 'Queue'); } - } + }); } /** @@ -1693,16 +1733,39 @@ function addQueueProducerOwners( const NAME_KEYED_BINDING_ARRAYS = new Set(['send_email', 'ratelimits']); /** - * Binding names a single config field contributes. Mirrors wrangler's - * `getBindingNames`, plus the `.name`-keyed arrays wrangler's own - * `getBindingNames` overlooks (see NAME_KEYED_BINDING_ARRAYS). + * F6 — config fields whose value uses the binding-LIST shape `{ bindings: [{ name }] }` + * (wrangler parses these via a `.bindings` sub-array, not a top-level array). Known + * field-awarely so a `{bindings: […]}` value is validated STRICTLY even when NO member + * is a record (`{bindings:[null]}` must throw, not fall through to a plain-map read), + * while a KEY-MAP field that merely happens to carry a `bindings` key (e.g. a `vars` + * entry named `bindings`) is never misread as a binding list. + */ +const BINDING_LIST_FIELDS = new Set(['durable_objects', 'unsafe', 'logfwdr']); + +/** + * F6 — config fields whose value is a SINGLE binding object `{ binding }` (wrangler + * parses these with a single-object validator, e.g. `validateAIBinding`). Known + * field-awarely so a PRESENT-but-non-string `binding` THROWS (fail closed) rather than + * yielding an empty set, WITHOUT misfiring on a KEY-MAP field (`vars`, blob/module + * maps) that could legitimately carry a key named `binding`. + */ +const SINGLETON_BINDING_FIELDS = new Set(['ai', 'browser', 'images', 'version_metadata', 'assets']); + +/** + * F6 — the ONE field-aware strict binding-name reader for a single config field. + * Mirrors wrangler's `getBindingNames` but is field-aware and FAILS CLOSED on any + * malformed/empty binding identity (wrangler is lenient and would silently drop such + * a member, letting the worker-wide uniqueness guard miss a real collision). Routes + * each field to its known shape: NAME-keyed arrays, binding LISTS, SINGLETON objects; + * everything else (namespace arrays, key-maps) goes through `genericBindingNames`, + * which itself fails closed on a malformed namespace-array member. */ function bindingNamesForField(field: string, value: unknown): Array { if (NAME_KEYED_BINDING_ARRAYS.has(field) && Array.isArray(value)) { // send_email / ratelimits: an array of `{ name }` (some also carry `.binding`). - // Collect BOTH — strictly. A malformed member (F6) REJECTS the whole config - // (fail closed) rather than being silently dropped, which would let the - // uniqueness guard miss a real binding name and commit a conflicting one. + // Collect BOTH — strictly. A malformed member REJECTS the whole config (fail + // closed) rather than being silently dropped, which would let the uniqueness + // guard miss a real binding name and commit a conflicting one. const names = strictBindingArray(value, 'name', field); const bindings = value .filter((entry): entry is Record => isRecord(entry)) @@ -1710,6 +1773,49 @@ function bindingNamesForField(field: string, value: unknown): Array { .filter((binding): binding is string => typeof binding === 'string' && binding.length > 0); return [...bindings, ...names]; } + if (BINDING_LIST_FIELDS.has(field)) { + return bindingListNames(field, value); + } + if (SINGLETON_BINDING_FIELDS.has(field)) { + return singletonBindingName(field, value); + } + return genericBindingNames(value); +} + +/** + * F6 — read the binding names from a binding-LIST field (`{ bindings: [{ name }] }`). + * When the value carries a `bindings` array, EVERY member is strictly validated for a + * non-empty string `name` (a malformed member throws — never a partial/plain-map read + * that could hide a real binding). A value that is not the `{bindings: array}` shape + * defers to the generic reader (which is itself strict on namespace arrays). + */ +function bindingListNames(field: string, value: unknown): Array { + if (isRecord(value) && Array.isArray(value.bindings)) { + return strictBindingArray(value.bindings, 'name', `${field}.bindings`); + } + return genericBindingNames(value); +} + +/** + * F6 — read the binding name from a SINGLETON field (`{ binding }`). A PRESENT + * `binding` that is not a non-empty string is malformed and THROWS (fail closed): + * wrangler's `getBindingNames` returns `[value.binding]` here with NO type check, so a + * non-string would slip in as a bogus name and a malformed one would silently + * contribute nothing. An ABSENT `binding` (e.g. `assets` with only a `directory`) + * defers to the generic reader, matching wrangler. + */ +function singletonBindingName(field: string, value: unknown): Array { + if ( + isRecord(value) && + value.binding !== undefined && + (typeof value.binding !== 'string' || value.binding.length === 0) + ) { + throw new Error( + `deploy: cannot verify binding-name uniqueness — "${field}" has a malformed "binding" ` + + `(expected a non-empty string). Refusing to proceed on a partially-readable binding set (a ` + + `dropped binding could hide a real collision). Fix the wrangler config and retry.`, + ); + } return genericBindingNames(value); } diff --git a/packages/void/test/unit/wrangler.test.ts b/packages/void/test/unit/wrangler.test.ts index d4a98da4b..97a4c4e59 100644 --- a/packages/void/test/unit/wrangler.test.ts +++ b/packages/void/test/unit/wrangler.test.ts @@ -2358,6 +2358,80 @@ describe('writeProvisionedWranglerConfig', () => { expect(readRaw('wrangler.jsonc')).toBe(before); }); + it('binding-list with ALL members malformed ({bindings:[null]}) throws — no plain-map fall-through (round-5 F6)', () => { + // The sibling gap round-4 left open: when NO member is a record, the old + // `some(isRecord)` guard fell through and read the field as a plain map (a bogus + // binding named "bindings"), hiding the malformed binding entirely. Field-aware + // strict validation throws even when every member is malformed. + setup('wrangler.jsonc', JSON.stringify({ durable_objects: { bindings: [null] } })); + const before = readRaw('wrangler.jsonc'); + expect(() => + writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'jobs', binding: 'QUEUE_JOBS' }] }, + }), + ).toThrow(/malformed entry at index 0 \(missing a non-empty string "name"\)/); + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('singleton binding with a non-string binding (ai:{binding:123}) throws — not a silent empty set (round-5 F6)', () => { + // wrangler's getBindingNames returns `[value.binding]` for a singleton with NO type + // check, so a non-string slips in as a bogus name and a malformed one contributes + // nothing. Field-aware strict validation rejects the whole config. + setup('wrangler.jsonc', JSON.stringify({ ai: { binding: 123 } })); + const before = readRaw('wrangler.jsonc'); + expect(() => + writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'jobs', binding: 'QUEUE_JOBS' }] }, + }), + ).toThrow(/"ai" has a malformed "binding"/); + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('malformed secrets.required member throws — never a silent skip (round-5 F6)', () => { + setup('wrangler.jsonc', JSON.stringify({ secrets: { required: ['API_KEY', null] } })); + const before = readRaw('wrangler.jsonc'); + expect(() => + writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'jobs', binding: 'QUEUE_JOBS' }] }, + }), + ).toThrow(/"secrets\.required" contains a malformed entry at index 1/); + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('malformed queues.producers member throws on the adopt path — never a silent skip (round-5 F6)', () => { + // The adopt guard layers existing producers into its owner map + // (addQueueProducerOwners); a null producer must reject the whole config rather + // than be dropped (which could hide a real producer-binding collision). + setup( + 'wrangler.jsonc', + JSON.stringify({ queues: { producers: [null, { queue: 'q', binding: 'QUEUE_X' }] } }), + ); + const before = readRaw('wrangler.jsonc'); + expect(() => + writeProvisionedWranglerConfig(tmpDir, { + d1: { binding: 'DB', databaseName: 'app', databaseId: 'real-uuid' }, + }), + ).toThrow(/"queues\.producers" contains a malformed entry at index 0/); + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('adopt TARGET array with a mixed valid+malformed member throws — not a silent skip (round-5 F6)', () => { + // Adopting into kv_namespaces whose array holds a null beside a valid { binding:'KV' }. + // The owner collector EXCLUDES the target field, so the malformed member is caught + // only by the strict target-field validation the adopt path now runs before mutating. + setup( + 'wrangler.jsonc', + JSON.stringify({ kv_namespaces: [null, { binding: 'KV', id: 'kv-id' }] }), + ); + const before = readRaw('wrangler.jsonc'); + expect(() => + writeProvisionedWranglerConfig(tmpDir, { + kv: { binding: 'KV', id: 'real-id' }, + }), + ).toThrow(/malformed entry at index 0 \(missing a non-empty string "binding"\)/); + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + it('migrations_dir fails closed when the D1 binding is declared more than once (no findIndex-first)', () => { setup( 'wrangler.jsonc', From 0de7919cc7bfbf68cda8136ede4ebc47bbbae742 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 16 Jul 2026 19:14:10 +0800 Subject: [PATCH 023/128] fix(wrangler): close codex round-6 F2/F5 in the binding collector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F2: export `assertNoDuplicateMembers` so the provisioner can run the SAME strict duplicate-member detection at its boundary (do not re-implement). F5: remove the binding-collector's fail-open bypasses — - route the producer-UPSERT path through the shared strict producer-list validator (`assertValidQueueProducers`, extracted from the adopt guard) so an existing `queues.producers:[null]` fails closed instead of committing a new producer beside a corrupt list; - add the omitted singleton binding fields `websearch`, `stream`, `media` (audited against wrangler 4.107's `validateNamedSimpleBinding` set — the complete singleton set is now covered); - encode each field's REQUIRED outer shape: a name-keyed field (`send_email`/`ratelimits`) that is present-but-not-an-array, and a binding-list field (`durable_objects`/`unsafe`/`logfwdr`) whose outer shape is wrong, now THROW rather than falling back to generic key collection (which could under-collect and hide a real binding collision). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/cli/wrangler.ts | 144 ++++++++++++++++++----- packages/void/test/unit/wrangler.test.ts | 79 +++++++++++++ 2 files changed, 195 insertions(+), 28 deletions(-) diff --git a/packages/void/src/cli/wrangler.ts b/packages/void/src/cli/wrangler.ts index b759ff634..90e4256a0 100644 --- a/packages/void/src/cli/wrangler.ts +++ b/packages/void/src/cli/wrangler.ts @@ -1091,6 +1091,13 @@ export function writeProvisionedWranglerConfig( // Re-parse `text` (not the initial `parsed`) so earlier edits in this call // are reflected; track appends locally to keep indices valid. const currentQueues = parseJsonc(text).queues as Record | undefined; + // F5 — before mutating producers, route the EXISTING `queues.producers` list + // through the SAME strict validator the adopt guard uses. Without this, a corrupt + // list (e.g. `[null]`) would be cloned, its non-record members silently skipped by + // the match loop below, and a new producer committed beside the corruption. + if (section === 'producers') { + assertValidQueueProducers(currentQueues?.producers); + } const currentList = Array.isArray(currentQueues?.[section]) ? [...(currentQueues[section] as Array>)] : []; @@ -1496,7 +1503,7 @@ function readTrailingComment( * silently ineffective. Walks the jsonc AST so nested objects (queue consumers, * D1 entries, …) are covered, not just the top level. */ -function assertNoDuplicateMembers(text: string, configPath: string): void { +export function assertNoDuplicateMembers(text: string, configPath: string): void { const root = parseTree(text, undefined, { allowTrailingComma: true }); if (!root) { return; @@ -1687,16 +1694,38 @@ function addQueueProducerOwners( if (!isRecord(queues) || queues.producers === undefined) { return; } - // F6 — validate `queues.producers` STRICTLY: a non-array, or any member missing a - // non-empty string `binding`, throws (fail closed) rather than being silently - // skipped, which would let the adopt guard miss a real producer-binding collision. - if (!Array.isArray(queues.producers)) { + // F5/F6 — validate `queues.producers` STRICTLY (the ONE shared validator, also used + // by the producer-upsert path) so a non-array or a malformed member fails closed + // rather than being silently skipped, which would let the adopt guard miss a real + // producer-binding collision. + for (const producer of assertValidQueueProducers(queues.producers)) { + const binding = producer.binding as string; + if (!owners.has(binding)) { + owners.set(binding, 'Queue'); + } + } +} + +/** + * F5 — the ONE strict `queues.producers` validator, shared by BOTH the adopt-guard's + * owner collection ({@link addQueueProducerOwners}) and the producer-upsert path (which + * otherwise clones the list and silently skips non-record members, committing a new + * producer beside a corrupt list). A non-array, or any member that is not a record with + * a non-empty string `binding`, THROWS (fail closed) rather than being dropped — a + * dropped/hidden producer could mask a real binding collision. Returns the validated + * producer records (each guaranteed to carry a non-empty string `binding`). + */ +function assertValidQueueProducers(producers: unknown): Array> { + if (producers === undefined) { + return []; + } + if (!Array.isArray(producers)) { throw new Error( `deploy: cannot verify binding-name uniqueness — "queues.producers" must be an array. Fix ` + `the wrangler config and retry.`, ); } - queues.producers.forEach((producer, index) => { + return producers.map((producer, index) => { if ( !isRecord(producer) || typeof producer.binding !== 'string' || @@ -1709,9 +1738,7 @@ function addQueueProducerOwners( `wrangler config and retry.`, ); } - if (!owners.has(producer.binding)) { - owners.set(producer.binding, 'Queue'); - } + return producer; }); } @@ -1744,12 +1771,31 @@ const BINDING_LIST_FIELDS = new Set(['durable_objects', 'unsafe', 'logfwdr']); /** * F6 — config fields whose value is a SINGLE binding object `{ binding }` (wrangler - * parses these with a single-object validator, e.g. `validateAIBinding`). Known - * field-awarely so a PRESENT-but-non-string `binding` THROWS (fail closed) rather than - * yielding an empty set, WITHOUT misfiring on a KEY-MAP field (`vars`, blob/module - * maps) that could legitimately carry a key named `binding`. + * parses these with a single-object validator, e.g. `validateAIBinding` / + * `validateNamedSimpleBinding` / `validateVersionMetadataBinding` / + * `validateAssetsConfig`). Known field-awarely so a PRESENT-but-non-string `binding` + * THROWS (fail closed) rather than yielding an empty set, WITHOUT misfiring on a + * KEY-MAP field (`vars`, blob/module maps) that could legitimately carry a key named + * `binding`. + * + * This is the COMPLETE singleton set per wrangler 4.107's config validator: `ai`, + * `browser`, `images`, `stream`, `media`, `websearch` (all `validateNamedSimpleBinding`), + * `version_metadata` (`validateVersionMetadataBinding`), and `assets` + * (`validateAssetsConfig`, whose `binding` is optional). Every OTHER + * `friendlyBindingNames` field is either an array (`validateBindingArray` — namespace + * list, covered by the generic strict path) or a binding LIST (`{ bindings }` — see + * BINDING_LIST_FIELDS), so none is missing here. */ -const SINGLETON_BINDING_FIELDS = new Set(['ai', 'browser', 'images', 'version_metadata', 'assets']); +const SINGLETON_BINDING_FIELDS = new Set([ + 'ai', + 'browser', + 'images', + 'stream', + 'media', + 'websearch', + 'version_metadata', + 'assets', +]); /** * F6 — the ONE field-aware strict binding-name reader for a single config field. @@ -1761,17 +1807,8 @@ const SINGLETON_BINDING_FIELDS = new Set(['ai', 'browser', 'images', 'version_me * which itself fails closed on a malformed namespace-array member. */ function bindingNamesForField(field: string, value: unknown): Array { - if (NAME_KEYED_BINDING_ARRAYS.has(field) && Array.isArray(value)) { - // send_email / ratelimits: an array of `{ name }` (some also carry `.binding`). - // Collect BOTH — strictly. A malformed member REJECTS the whole config (fail - // closed) rather than being silently dropped, which would let the uniqueness - // guard miss a real binding name and commit a conflicting one. - const names = strictBindingArray(value, 'name', field); - const bindings = value - .filter((entry): entry is Record => isRecord(entry)) - .map((entry) => entry.binding) - .filter((binding): binding is string => typeof binding === 'string' && binding.length > 0); - return [...bindings, ...names]; + if (NAME_KEYED_BINDING_ARRAYS.has(field)) { + return nameKeyedBindingNames(field, value); } if (BINDING_LIST_FIELDS.has(field)) { return bindingListNames(field, value); @@ -1782,6 +1819,34 @@ function bindingNamesForField(field: string, value: unknown): Array { return genericBindingNames(value); } +/** + * F5 — read binding names from a NAME-keyed array field (`send_email` / `ratelimits`: + * an array of `{ name }`, some also carrying `.binding`). Encodes the field's REQUIRED + * outer shape: a PRESENT value that is not an array THROWS (fail closed) rather than + * falling back to generic key collection — wrangler validates these via + * `validateBindingArray`, so a non-array is malformed and could hide a real collision + * if silently re-read as a key-map. An ABSENT value contributes nothing. Every array + * member is strictly validated (a malformed one rejects the whole config). + */ +function nameKeyedBindingNames(field: string, value: unknown): Array { + if (value === undefined) { + return []; + } + if (!Array.isArray(value)) { + throw new Error( + `deploy: cannot verify binding-name uniqueness — "${field}" must be an array of binding ` + + `entries. Refusing to proceed on a partially-readable binding set (a wrong-shaped field ` + + `could hide a real collision). Fix the wrangler config and retry.`, + ); + } + const names = strictBindingArray(value, 'name', field); + const bindings = value + .filter((entry): entry is Record => isRecord(entry)) + .map((entry) => entry.binding) + .filter((binding): binding is string => typeof binding === 'string' && binding.length > 0); + return [...bindings, ...names]; +} + /** * F6 — read the binding names from a binding-LIST field (`{ bindings: [{ name }] }`). * When the value carries a `bindings` array, EVERY member is strictly validated for a @@ -1790,10 +1855,33 @@ function bindingNamesForField(field: string, value: unknown): Array { * defers to the generic reader (which is itself strict on namespace arrays). */ function bindingListNames(field: string, value: unknown): Array { - if (isRecord(value) && Array.isArray(value.bindings)) { - return strictBindingArray(value.bindings, 'name', `${field}.bindings`); + if (value === undefined) { + return []; } - return genericBindingNames(value); + // Encode the REQUIRED outer shape: a binding-list field must be an object; a + // wrong-shaped value (array, string, …) THROWS rather than falling back to generic + // key collection (which could under-collect and hide a real binding collision). + if (!isRecord(value)) { + throw new Error( + `deploy: cannot verify binding-name uniqueness — "${field}" must be an object with a ` + + `"bindings" array. Refusing to proceed on a partially-readable binding set (a wrong-shaped ` + + `field could hide a real collision). Fix the wrangler config and retry.`, + ); + } + // An object with NO `bindings` key contributes no binding names (e.g. `unsafe` + // carrying only `metadata`), matching wrangler — but a PRESENT `bindings` that is + // not an array is malformed and fails closed. + if (value.bindings === undefined) { + return []; + } + if (!Array.isArray(value.bindings)) { + throw new Error( + `deploy: cannot verify binding-name uniqueness — "${field}.bindings" must be an array. ` + + `Refusing to proceed on a partially-readable binding set (a wrong-shaped field could hide ` + + `a real collision). Fix the wrangler config and retry.`, + ); + } + return strictBindingArray(value.bindings, 'name', `${field}.bindings`); } /** diff --git a/packages/void/test/unit/wrangler.test.ts b/packages/void/test/unit/wrangler.test.ts index 97a4c4e59..07df94259 100644 --- a/packages/void/test/unit/wrangler.test.ts +++ b/packages/void/test/unit/wrangler.test.ts @@ -2450,4 +2450,83 @@ describe('writeProvisionedWranglerConfig', () => { ).toThrow(/binding "DB" is declared 2 times in "d1_databases"/); expect(readRaw('wrangler.jsonc')).toBe(before); }); + + it('producer upsert fails closed when the EXISTING queues.producers list has a null member (round-6 F5)', () => { + // The producer-upsert path previously cloned queues.producers and silently skipped + // non-record members, committing a new producer beside a corrupt list. It now routes + // the existing list through the SAME strict validator the adopt guard uses. + setup('wrangler.jsonc', JSON.stringify({ queues: { producers: [null] } })); + const before = readRaw('wrangler.jsonc'); + expect(() => + writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'jobs', binding: 'QUEUE_JOBS' }] }, + }), + ).toThrow(/"queues\.producers" contains a malformed entry at index 0/); + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('collector fails closed on a malformed stream/media/websearch singleton binding (round-6 F5)', () => { + // These singleton `{ binding }` fields were OMITTED from the strict set, so a + // present-but-non-string binding silently contributed nothing (wrangler quirk). + for (const field of ['stream', 'media', 'websearch']) { + setup('wrangler.jsonc', JSON.stringify({ [field]: { binding: 123 } })); + const before = readRaw('wrangler.jsonc'); + expect(() => + writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'jobs', binding: 'QUEUE_JOBS' }] }, + }), + ).toThrow(new RegExp(`"${field}" has a malformed "binding"`)); + expect(readRaw('wrangler.jsonc')).toBe(before); + } + }); + + it('collector fails closed on a wrong-shaped binding-list field (durable_objects as array) (round-6 F5)', () => { + // durable_objects must be `{ bindings: [...] }`; an array is the wrong outer shape + // and must throw rather than fall back to generic key collection (under-collection + // could hide a real binding collision). + setup('wrangler.jsonc', JSON.stringify({ durable_objects: [{ name: 'DO', class_name: 'C' }] })); + const before = readRaw('wrangler.jsonc'); + expect(() => + writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'jobs', binding: 'QUEUE_JOBS' }] }, + }), + ).toThrow(/"durable_objects" must be an object with a "bindings" array/); + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('collector fails closed on a wrong-shaped name-keyed field (send_email as object) (round-6 F5)', () => { + // send_email must be an array of `{ name }`; a non-array is the wrong outer shape and + // must throw rather than be re-read as a key-map. + setup('wrangler.jsonc', JSON.stringify({ send_email: { EMAIL: {} } })); + const before = readRaw('wrangler.jsonc'); + expect(() => + writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'jobs', binding: 'QUEUE_JOBS' }] }, + }), + ).toThrow(/"send_email" must be an array of binding entries/); + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('negative control: valid stream/media/websearch singletons + unsafe-with-only-metadata do NOT throw (round-6 F5)', () => { + // A well-formed singleton contributes its binding name; `unsafe` with only metadata + // (no bindings) contributes nothing — neither should trip the strict collector. + setup( + 'wrangler.jsonc', + JSON.stringify({ + stream: { binding: 'STREAM' }, + media: { binding: 'MEDIA' }, + websearch: { binding: 'SEARCH' }, + unsafe: { metadata: { foo: 'bar' } }, + }), + ); + const result = writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'jobs', binding: 'QUEUE_JOBS' }] }, + }); + expect(result.modified).toBe(true); + const parsed = parseRaw('wrangler.jsonc'); + const producers = (parsed.queues as Record).producers as Array< + Record + >; + expect(producers).toEqual([{ queue: 'jobs', binding: 'QUEUE_JOBS' }]); + }); }); From 46bb6c17ce9ae62028f90cd4b1914126ed18d463 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 16 Jul 2026 19:14:25 +0800 Subject: [PATCH 024/128] fix(provision): close codex round-6 F1/F2/F3/F4/F6 in the CF provisioner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F1 (CRITICAL): thread a required transport account context into EVERY wrangler + Hyperdrive invocation (default AND injected). `RunWrangler`'s call context and the `ListHyperdriveConfigs` seam now carry the reconciled `accountId`; a single runWrangler choke point injects it into every call, and the lister is always invoked with `{ accountId }`, so an injected seam can never query a DIFFERENT account than the config is pinned to. F2: run the writer's strict duplicate-member detector at the provisioner BOUNDARY (before account persistence, discovery, or any remote side effect), so a config with duplicated members (e.g. two `kv_namespaces`) fails closed instead of reaching a create (orphan) or being falsely reused. F3: apply ONE strict Hyperdrive-list validator (`selectHyperdriveMatch`) at the CALLER after either lister returns — rejecting non-object/empty-id/ empty-name/duplicate-id members and requiring exactly 0/1 exact-name match — so an injected malformed list can no longer be adopted with an empty id. F4: make `assertCreateWroteRealId` resource-specific + identity-checked — R2 `bucket_name` and D1 `database_name` must EXACTLY equal the requested name (not merely be non-placeholder), so a create that wrote a different resource fails closed. F6: serialize the unguarded Hyperdrive list->create across processes with a cross-process lockfile keyed on the resolved config path (O_EXCL create, `{ pid, timestamp }`, stale-break on dead PID / bounded timeout, guaranteed release in `finally`). The read-first check runs INSIDE the lock, so a second concurrent provision reuses the first's id instead of minting a duplicate. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/cli/provision.ts | 483 ++++++++++++++++------ packages/void/test/unit/provision.test.ts | 350 +++++++++++++++- 2 files changed, 709 insertions(+), 124 deletions(-) diff --git a/packages/void/src/cli/provision.ts b/packages/void/src/cli/provision.ts index ca0a40615..d162b403b 100644 --- a/packages/void/src/cli/provision.ts +++ b/packages/void/src/cli/provision.ts @@ -1,10 +1,14 @@ import { spawnSync } from 'node:child_process'; -import { existsSync, readFileSync } from 'node:fs'; +import { closeSync, existsSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { parse, type ParseError } from 'jsonc-parser'; import { resolve } from 'pathe'; import { voidLog, voidWarn } from '../log.ts'; import { resolveWranglerArgv } from './preset.ts'; -import { writeProvisionedWranglerConfig, type WriteProvisionedWranglerResult } from './wrangler.ts'; +import { + assertNoDuplicateMembers, + writeProvisionedWranglerConfig, + type WriteProvisionedWranglerResult, +} from './wrangler.ts'; /** * The set of Cloudflare resources an app needs, computed by the deploy @@ -61,10 +65,22 @@ export type RunWranglerResult = { * array element and is passed literally to the process (no shell), so shell * metacharacters (`&`, `$`, `;`, spaces) can never split, inject, or drop args. */ -export type RunWrangler = ( - args: Array, - opts?: { cwd?: string; captureJson?: boolean }, -) => RunWranglerResult; +/** + * F1 — the transport context threaded into EVERY wrangler invocation (default AND + * injected). Its `accountId` is the ONE reconciled effective account this whole + * provision is pinned to: the default runner uses it to pin the spawned wrangler's + * env (`CLOUDFLARE_ACCOUNT_ID` + the top-level `--env=` on every argv), and an + * injected seam MUST bind its own transport to the SAME account (so it can never + * query account B while the config pins A and write a same-name B result into A). + */ +export interface WranglerCallContext { + /** The reconciled effective Cloudflare account (never empty for a real call). */ + accountId: string; + cwd?: string; + captureJson?: boolean; +} + +export type RunWrangler = (args: Array, opts: WranglerCallContext) => RunWranglerResult; /** A Cloudflare Hyperdrive config as returned by the account-list existence check. */ export interface HyperdriveConfig { @@ -79,8 +95,14 @@ export interface HyperdriveConfig { * ({@link createDefaultListHyperdriveConfigs}) calls * `GET /accounts/{id}/hyperdrive/configs`; unit tests inject a stub so no real * network call runs. + * + * F1 — the lister receives the reconciled `accountId` as an explicit transport + * context (never a closure-only binding), so an injected lister can never query a + * DIFFERENT account than the one the config is pinned to. */ -export type ListHyperdriveConfigs = () => Promise>; +export type ListHyperdriveConfigs = (ctx: { + accountId: string; +}) => Promise>; /** Per-resource outcome, so the orchestrator can log what happened. */ export interface ProvisionOutcome { @@ -168,18 +190,18 @@ export function provisionEnv(accountId?: string): NodeJS.ProcessEnv { * injection). Captures stdout (for `--json` parsing) and stderr (for * error/collision detection). Never called in unit tests — those inject a stub. */ -export function createDefaultRunWrangler( - root: string, - options?: { accountId?: string }, -): RunWrangler { +export function createDefaultRunWrangler(root: string): RunWrangler { const launcher = resolveWranglerArgv(root); const [command, ...prefix] = launcher; return (args, opts) => { const result = spawnSync(command, [...prefix, ...args], { - cwd: opts?.cwd ?? root, + cwd: opts.cwd ?? root, shell: false, encoding: 'utf-8', - env: provisionEnv(options?.accountId), + // F1 — pin the account PER CALL from the transport context, so the spawned + // wrangler targets the SAME reconciled account the config + Hyperdrive REST + // path use (no split-brain between the config pin and the ambient env). + env: provisionEnv(opts.accountId), stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }); @@ -264,15 +286,14 @@ function isCleanFailure(result: RunWranglerResult): boolean { * it cannot be built (no account id / no token) — the caller then fails closed * before any blind create. Never called in unit tests — those inject a stub. */ -function createDefaultListHyperdriveConfigs( - accountId?: string, - apiToken?: string, -): ListHyperdriveConfigs | undefined { +function createDefaultListHyperdriveConfigs(apiToken?: string): ListHyperdriveConfigs | undefined { const token = apiToken ?? process.env.CLOUDFLARE_API_TOKEN; - if (!accountId || !token) { + if (!token) { return undefined; } - return async () => { + // F1 — the account comes from the per-call transport context (not a closure), so + // this default REST lister always targets the reconciled effective account. + return async ({ accountId }) => { // Fetch ALL pages (Hyperdrive names are NOT unique — paid accounts allow // ~25 configs, paginated) and FAIL CLOSED on any non-2xx, `success !== true`, // or a malformed entry. Never coerce a malformed envelope/entry to "absence": @@ -363,10 +384,190 @@ function createDefaultListHyperdriveConfigs( break; } } + // NOTE: the per-entry id/name + duplicate-id checks above are needed for this REST + // path's pagination-completeness PROOF (the `byId` map). They are NOT the adoption + // gate: `selectHyperdriveMatch` at the CALLER is the single authoritative validator + // that covers BOTH this default path AND any injected lister (F3). return [...byId.values()]; }; } +/** + * F3 — the ONE strict Hyperdrive-list validator, applied at the CALLER after EITHER + * lister returns (the default fetch OR an injected seam), so validation can never live + * only inside the default fetch. Rejects any non-object member, empty/non-string `id` + * or `name`, and duplicate `id`; then requires EXACTLY 0 or 1 exact-name match (>1 is + * ambiguous — Hyperdrive names are NOT account-unique — and fails closed) BEFORE any + * match/write. Returns the single exact-name match, or `undefined` when none matches. + * Without this, an injected `[{ id:'', name: requested }]` would be adopted with an + * EMPTY id. + */ +function selectHyperdriveMatch( + configs: Array, + name: string, + configPath: string, +): HyperdriveConfig | undefined { + const seenIds = new Set(); + const validated: Array = []; + for (const config of configs) { + if ( + !isRecord(config) || + typeof config.id !== 'string' || + config.id.length === 0 || + typeof config.name !== 'string' || + config.name.length === 0 + ) { + throw new Error( + `provision: the Hyperdrive existence check returned a malformed config entry (each must be ` + + `an object with a non-empty string id and name). Cannot verify whether ` + + `${JSON.stringify(name)} already exists; refusing to create a possible duplicate.`, + ); + } + if (seenIds.has(config.id)) { + throw new Error( + `provision: the Hyperdrive existence check returned a duplicate config id ` + + `${JSON.stringify(config.id)}. Cannot verify existing configs; refusing to create a ` + + `possible duplicate.`, + ); + } + seenIds.add(config.id); + validated.push({ id: config.id, name: config.name }); + } + const named = validated.filter((c) => c.name === name); + if (named.length > 1) { + throw new Error( + `provision: cannot adopt Hyperdrive config ${JSON.stringify(name)} — ${named.length} configs ` + + `in this account share that name (ids: ${named.map((c) => c.id).join(', ')}). Hyperdrive ` + + `names are not account-unique; remove the duplicates or pin the id in "${configPath}", then ` + + `retry.`, + ); + } + return named[0]; +} + +// --- F6: cross-process provision lock ------------------------------------------ +// Hyperdrive is an unguarded list→create and its names are NOT account-unique, so two +// concurrent provisions could both see "no match" and both create — a permanent +// orphaned duplicate that read-first then hides forever. Serialize the list→create +// across processes with a lockfile keyed on the resolved config path (O_EXCL create). +// A second concurrent run blocks until the first releases, then its read-first (done +// INSIDE the lock) sees the id the first wrote and SKIPS. + +interface ProvisionLock { + release: () => void; +} + +/** + * A lock older than this — or whose holder PID is dead — is STALE and safe to break, so + * a crashed provision cannot wedge every future one. Generous vs. a real provision's + * duration. + */ +const LOCK_STALE_MS = 120_000; +/** How long to wait for a held (non-stale) lock before giving up. */ +const LOCK_ACQUIRE_TIMEOUT_MS = 60_000; +/** Poll interval while waiting for a held lock. */ +const LOCK_POLL_MS = 50; + +const lockDelay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * True when a PID is still alive. `process.kill(pid, 0)` sends NO signal but throws + * `ESRCH` for a dead process and `EPERM` for a live one we may not signal (still alive). + */ +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM'; + } +} + +/** + * Decide whether a currently-held lockfile is STALE (safe to break): a corrupt/malformed + * lock, one older than {@link LOCK_STALE_MS}, or one whose holder PID is dead. A lock + * that VANISHED (already released between the EEXIST and this read) is NOT stale — the + * caller's retry re-acquires it cleanly. + */ +function isStaleLock(lockPath: string): boolean { + let raw: string; + try { + raw = readFileSync(lockPath, 'utf-8'); + } catch { + return false; + } + let data: unknown; + try { + data = JSON.parse(raw); + } catch { + return true; + } + if (!isRecord(data) || typeof data.pid !== 'number' || typeof data.timestamp !== 'number') { + return true; + } + if (Date.now() - data.timestamp > LOCK_STALE_MS) { + return true; + } + return !isProcessAlive(data.pid); +} + +/** + * Acquire the cross-process provision lock via an O_EXCL (`'wx'`) create holding + * `{ pid, timestamp }`. Breaks a STALE lock (dead PID / too old), waits out a live one + * up to {@link LOCK_ACQUIRE_TIMEOUT_MS}, and returns a handle whose `release()` removes + * the lockfile. ALWAYS call `release()` in a `finally` so an interruption never leaks + * the lock. + */ +async function acquireProvisionLock(configPath: string): Promise { + const lockPath = `${configPath}.void-provision.lock`; + const deadline = Date.now() + LOCK_ACQUIRE_TIMEOUT_MS; + for (;;) { + try { + const fd = openSync(lockPath, 'wx'); + try { + writeFileSync(fd, JSON.stringify({ pid: process.pid, timestamp: Date.now() })); + } finally { + closeSync(fd); + } + let released = false; + return { + release: () => { + if (released) { + return; + } + released = true; + try { + rmSync(lockPath, { force: true }); + } catch { + // best-effort: a stale-sweep or manual cleanup may have removed it already + } + }, + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { + throw error; + } + // Held by someone. Break it if stale; otherwise wait and retry until the deadline. + if (isStaleLock(lockPath)) { + try { + rmSync(lockPath, { force: true }); + } catch { + // another racer may have broken it first — just retry the O_EXCL create + } + continue; + } + if (Date.now() >= deadline) { + throw new Error( + `provision: timed out after ${LOCK_ACQUIRE_TIMEOUT_MS}ms waiting for the provision lock ` + + `at "${lockPath}". Another provision is running for this config; retry once it finishes, ` + + `or remove a stale lockfile.`, + ); + } + await lockDelay(LOCK_POLL_MS); + } + } +} + /** * Parse a wrangler `--json` stdout into an array, distinguishing a VALID JSON * array (returns it, possibly empty) from unparseable/non-array output (returns @@ -702,6 +903,15 @@ interface CreatePostcondition { field: string; /** The member key that must hold a REAL (non-placeholder) id afterward. */ idField: string; + /** + * F4 — when set, the member key that must EXACTLY equal the requested name (the + * resource's IDENTITY). R2's `bucket_name` and D1's `database_name` ARE the + * identity, so a create that wrote a DIFFERENT resource's name (a race, a stale + * `--update-config`, a wrong-account write) must fail closed rather than binding the + * app to the wrong resource. KV/Hyperdrive config entries carry no name to + * cross-check, so they verify only a real id. + */ + nameField?: string; /** Human resource name for the message. */ resource: string; /** Command name for the message. */ @@ -709,16 +919,20 @@ interface CreatePostcondition { } /** - * F4 backstop — verify a create's POSTCONDITION. wrangler 4.107's `bin.wrangler` - * is a WRAPPER that spawns `wrangler-dist/cli.js` and maps a signal-killed child - * (`code === null`) to `process.exit(0)`, so `spawnSync` can see a `create` report - * "success" though the real CLI was killed and `--update-config` wrote NO id. - * After every config-writing create (d1/kv/r2/hyperdrive) reports success we - * RE-READ the resolved config and CONFIRM the target binding now holds a REAL id; - * otherwise we FAIL CLOSED. This also catches any partial/silent create failure, - * not only kills. (Queue creates write NO config, so they cannot be verified this - * way — a killed queue-create self-heals on the next read-first/probe run, which - * re-detects absence and re-creates.) + * F4 backstop — verify a create's POSTCONDITION, RESOURCE-SPECIFICALLY + identity + * checked. wrangler 4.107's `bin.wrangler` is a WRAPPER that spawns + * `wrangler-dist/cli.js` and maps a signal-killed child (`code === null`) to + * `process.exit(0)`, so `spawnSync` can see a `create` report "success" though the + * real CLI was killed and `--update-config` wrote NO id. After every config-writing + * create (d1/kv/r2/hyperdrive) reports success we RE-READ the resolved config and + * CONFIRM the target binding now holds a REAL id AND — where the resource's name is + * its identity (R2 `bucket_name`, D1 `database_name`) — that the recorded name EXACTLY + * matches the requested one; otherwise we FAIL CLOSED. A bare "some non-placeholder + * value" check is NOT enough: for R2, `bucket_name` IS the identity, so + * `{ bucket_name: 'other' }` would otherwise pass and bind the WRONG bucket. This also + * catches any partial/silent create failure, not only kills. (Queue creates write NO + * config, so they cannot be verified this way — a killed queue-create self-heals on + * the next read-first/probe run, which re-detects absence and re-creates.) */ function assertCreateWroteRealId( configPath: string, @@ -727,12 +941,16 @@ function assertCreateWroteRealId( name: string, ): void { const entry = findBindingEntry(readRootConfig(configPath)[spec.field], binding); - if (!entry || !isRealValue(entry[spec.idField])) { + const idOk = !!entry && isRealValue(entry[spec.idField]); + const nameOk = spec.nameField === undefined || (!!entry && entry[spec.nameField] === name); + if (!idOk || !nameOk) { throw new Error( - `provision: "${spec.command} ${name}" reported success but "${configPath}" holds no real ` + - `${spec.resource} id for binding ${JSON.stringify(binding)} afterward. The create may have ` + - `been interrupted — wrangler's launcher can mask a signal-killed child as exit 0. Refusing ` + - `to record an unprovisioned resource; inspect the account/config and retry.`, + `provision: "${spec.command} ${name}" reported success but "${configPath}" does not hold the ` + + `expected ${spec.resource} for binding ${JSON.stringify(binding)} afterward (need a real ` + + `${spec.idField}${spec.nameField ? ` and ${spec.nameField} === ${JSON.stringify(name)}` : ''}). ` + + `The create may have been interrupted or written a different resource — wrangler's launcher ` + + `can mask a signal-killed child as exit 0. Refusing to record a wrong/unprovisioned ` + + `resource; inspect the account/config and retry.`, ); } } @@ -813,6 +1031,20 @@ export async function provisionCloudflareResources( // creates against another (orphan). An already-absolute path is returned as-is. const configPath = resolve(root, rootWranglerPath); + // --- F2: strict duplicate-member guard at the PROVISIONER BOUNDARY -------- + // `jsonc-parser.parse()` (used by `readRootConfig`) does NOT flag duplicate object + // members, and the Task 3 writer's duplicate-member guard only runs when the writer + // is invoked — which is SKIPPED when `account_id` is already correct. So without + // this, a config with duplicated members (e.g. two `kv_namespaces` arrays) could + // reach `kv namespace create` (orphan) or be falsely "reused". Run the SAME strict + // detection the writer uses (reused, not re-implemented) at the boundary — before + // account persistence, discovery, or ANY remote side effect — and fail closed. + if (existsSync(configPath)) { + const raw = readFileSync(configPath, 'utf-8'); + const content = raw.startsWith(UTF8_BOM) ? raw.slice(UTF8_BOM.length) : raw; + assertNoDuplicateMembers(content, configPath); + } + // --- F2: resource-name validation (BEFORE any argv / remote work) --------- // Every name that becomes a wrangler ARGV POSITIONAL is validated up front, so an // option-like name (`--help`, `-h`, `--config`) can never reach wrangler and be @@ -910,13 +1142,26 @@ export async function provisionCloudflareResources( accountIdWritten = true; } + // F1 — the reconciled effective account, threaded as the transport context into + // EVERY wrangler + Hyperdrive invocation below (default AND injected). The boundary + // fence above guarantees a resolved account for ALL remote work, so this non-null + // assertion is sound wherever a seam is actually called (an empty `desired` reaches + // no seam and never reads it). + const transportAccountId = effectiveAccountId!; + // Both seams are pinned to the SAME effective account: the default wrangler runner // via its env (`CLOUDFLARE_ACCOUNT_ID` + the top-level `--env=` on every argv), and - // the default Hyperdrive lister via `createDefaultListHyperdriveConfigs` - // (below). An injected seam owns its own transport but only ever runs past the - // fence above, which guarantees a resolved + persisted account. - const runWrangler = - opts?.runWrangler ?? createDefaultRunWrangler(root, { accountId: effectiveAccountId }); + // the default Hyperdrive lister via `createDefaultListHyperdriveConfigs` (below). + // An injected seam owns its own transport but is ALWAYS invoked WITH the reconciled + // account (via the single choke point below / the lister's `{ accountId }` context), + // so it can never target a DIFFERENT account than the one the config is pinned to. + const selectedRunWrangler = opts?.runWrangler ?? createDefaultRunWrangler(root); + // Single choke point: inject the reconciled account into EVERY call so no call site + // can forget it (grep-proven — every `runWrangler(...)` below routes through here). + const runWrangler = ( + args: Array, + callOpts?: { cwd?: string; captureJson?: boolean }, + ): RunWranglerResult => selectedRunWrangler(args, { ...callOpts, accountId: transportAccountId }); // --- D1 ------------------------------------------------------------------ if (desired.d1) { @@ -1007,6 +1252,7 @@ export async function provisionCloudflareResources( { field: 'd1_databases', idField: 'database_id', + nameField: 'database_name', resource: 'D1 database', command: 'wrangler d1 create', }, @@ -1186,6 +1432,7 @@ export async function provisionCloudflareResources( { field: 'r2_buckets', idField: 'bucket_name', + nameField: 'bucket_name', resource: 'R2 bucket', command: 'wrangler r2 bucket create', }, @@ -1216,92 +1463,90 @@ export async function provisionCloudflareResources( // attempted BEFORE the connection-string gate (it needs no connection string). if (desired.hyperdrive) { const { binding, name, connectionString } = desired.hyperdrive; - const entry = findBindingEntry(readRootConfig(configPath).hyperdrive, binding); - if (entry && isRealValue(entry.id)) { - resources.push({ kind: 'hyperdrive', binding, name, action: 'reused', id: entry.id }); - } else { - const lister = - opts?.listHyperdriveConfigs ?? - createDefaultListHyperdriveConfigs(effectiveAccountId, opts?.apiToken); - let configs: Array | undefined; - let existenceUnknown = true; - if (lister) { - try { - configs = await lister(); - existenceUnknown = false; - } catch (error) { - log.warn(`Hyperdrive existence check for ${name} failed: ${(error as Error).message}`); - existenceUnknown = true; + // F6 — serialize the unguarded list→create across processes with a cross-process + // lock keyed on the resolved config path. The read-first check is done INSIDE the + // lock so a second concurrent provision, after acquiring the lock, sees the id the + // first wrote and SKIPS (no duplicate). Always released in `finally`, incl. on any + // throw below (a leaked lock would wedge the next run until the stale timeout). + const lock = await acquireProvisionLock(configPath); + try { + const entry = findBindingEntry(readRootConfig(configPath).hyperdrive, binding); + if (entry && isRealValue(entry.id)) { + resources.push({ kind: 'hyperdrive', binding, name, action: 'reused', id: entry.id }); + } else { + const lister = + opts?.listHyperdriveConfigs ?? createDefaultListHyperdriveConfigs(opts?.apiToken); + let configs: Array | undefined; + let existenceUnknown = true; + if (lister) { + try { + // F1 — always invoke the lister WITH the reconciled account, so an injected + // lister can never query a DIFFERENT account than the config is pinned to. + configs = await lister({ accountId: transportAccountId }); + existenceUnknown = false; + } catch (error) { + log.warn(`Hyperdrive existence check for ${name} failed: ${(error as Error).message}`); + existenceUnknown = true; + } } - } - // Require EXACTLY 0 or 1 exact-name match. Hyperdrive names are NOT unique, - // so >1 is ambiguous — adoption cannot pick one, and a blind create would - // mint yet another duplicate. Fail closed on ambiguity (regardless of the - // connection string). - let existing: HyperdriveConfig | undefined; - if (configs) { - const named = configs.filter((c) => c.name === name); - if (named.length > 1) { + // F3 — apply the ONE strict Hyperdrive-list validator at the CALLER, AFTER the + // lister returns (default OR injected). It rejects malformed/empty/duplicate + // entries and requires EXACTLY 0 or 1 exact-name match (>1 → fail closed) BEFORE + // matching or writing, so no injected list can be adopted with an empty/garbage id. + const existing = configs ? selectHyperdriveMatch(configs, name, configPath) : undefined; + if (existing) { + log.info(`adopted existing Hyperdrive config ${name} (${existing.id})`); + write({ hyperdrive: { binding, id: existing.id } }); + resources.push({ kind: 'hyperdrive', binding, name, action: 'adopted', id: existing.id }); + } else if (!connectionString) { + // No connection string → we can never CREATE, so there is no duplicate + // risk whether or not existence is known: skip (structured, never prompts). + const reason = 'no connection string provided'; + log.warn(`skipping Hyperdrive config ${name}: ${reason}`); + resources.push({ kind: 'hyperdrive', binding, name, action: 'skipped', reason }); + } else if (existenceUnknown) { + // A connection string is present (we WOULD create) but existence is + // unverifiable → FAIL CLOSED. Blindly creating duplicates a same-named config. throw new Error( - `provision: cannot adopt Hyperdrive config ${JSON.stringify(name)} — ${named.length} ` + - `configs in this account share that name (ids: ${named.map((c) => c.id).join(', ')}). ` + - `Hyperdrive names are not account-unique; remove the duplicates or pin the id in ` + - `"${configPath}", then retry.`, + `provision: cannot verify whether Hyperdrive config ${JSON.stringify(name)} already ` + + `exists (no existence check available — provide an accountId + API token, or inject a ` + + `listHyperdriveConfigs seam). "wrangler hyperdrive create" silently duplicates a ` + + `same-named config, so provisioning refuses to create it blindly.`, ); - } - existing = named[0]; - } - if (existing) { - log.info(`adopted existing Hyperdrive config ${name} (${existing.id})`); - write({ hyperdrive: { binding, id: existing.id } }); - resources.push({ kind: 'hyperdrive', binding, name, action: 'adopted', id: existing.id }); - } else if (!connectionString) { - // No connection string → we can never CREATE, so there is no duplicate - // risk whether or not existence is known: skip (structured, never prompts). - const reason = 'no connection string provided'; - log.warn(`skipping Hyperdrive config ${name}: ${reason}`); - resources.push({ kind: 'hyperdrive', binding, name, action: 'skipped', reason }); - } else if (existenceUnknown) { - // A connection string is present (we WOULD create) but existence is - // unverifiable → FAIL CLOSED. Blindly creating duplicates a same-named - // config. - throw new Error( - `provision: cannot verify whether Hyperdrive config ${JSON.stringify(name)} already ` + - `exists (no existence check available — provide an accountId + API token, or inject a ` + - `listHyperdriveConfigs seam). "wrangler hyperdrive create" silently duplicates a ` + - `same-named config, so provisioning refuses to create it blindly.`, - ); - } else { - // Confirmed absent + connection string present → create. - const created = runWrangler([ - 'hyperdrive', - 'create', - name, - `--connection-string=${connectionString}`, - '--binding', - binding, - '--update-config', - ...wranglerConfigArgs(configPath), - ]); - if (!isCleanSuccess(created)) { - throw new Error( - `provision: "wrangler hyperdrive create ${name}" failed: ${created.stderr || created.stdout}`, + } else { + // Confirmed absent + connection string present → create. + const created = runWrangler([ + 'hyperdrive', + 'create', + name, + `--connection-string=${connectionString}`, + '--binding', + binding, + '--update-config', + ...wranglerConfigArgs(configPath), + ]); + if (!isCleanSuccess(created)) { + throw new Error( + `provision: "wrangler hyperdrive create ${name}" failed: ${created.stderr || created.stdout}`, + ); + } + // F4 postcondition: confirm --update-config wrote a REAL Hyperdrive id. + assertCreateWroteRealId( + configPath, + { + field: 'hyperdrive', + idField: 'id', + resource: 'Hyperdrive config', + command: 'wrangler hyperdrive create', + }, + binding, + name, ); + resources.push({ kind: 'hyperdrive', binding, name, action: 'created' }); } - // F4 postcondition: confirm --update-config wrote a REAL Hyperdrive id. - assertCreateWroteRealId( - configPath, - { - field: 'hyperdrive', - idField: 'id', - resource: 'Hyperdrive config', - command: 'wrangler hyperdrive create', - }, - binding, - name, - ); - resources.push({ kind: 'hyperdrive', binding, name, action: 'created' }); } + } finally { + lock.release(); } } diff --git a/packages/void/test/unit/provision.test.ts b/packages/void/test/unit/provision.test.ts index 314c0d0d0..9d0d2dba6 100644 --- a/packages/void/test/unit/provision.test.ts +++ b/packages/void/test/unit/provision.test.ts @@ -1,4 +1,4 @@ -import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { parse as parseJsoncRaw } from 'jsonc-parser'; import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'; @@ -2108,7 +2108,9 @@ describe('provisionCloudflareResources — F4 create postcondition (round 4)', ( { ...emptyDesired, kvNamespaces: [{ binding: 'KV', title: 'myapp-kv' }] }, { runWrangler: run, log: silentLog }, ), - ).rejects.toThrow(/reported success but .* holds no real KV namespace id/s); + ).rejects.toThrow( + /reported success but .* does not hold the expected KV namespace .*need a real id/s, + ); expect(calls.some((c) => isCall(c, 'kv', 'namespace', 'create'))).toBe(true); expect(readConfig().kv_namespaces).toBeUndefined(); }); @@ -2126,7 +2128,9 @@ describe('provisionCloudflareResources — F4 create postcondition (round 4)', ( { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, { runWrangler: run, log: silentLog }, ), - ).rejects.toThrow(/reported success but .* holds no real D1 database id/s); + ).rejects.toThrow( + /reported success but .* does not hold the expected D1 database .*need a real database_id/s, + ); expect(readConfig().d1_databases).toBeUndefined(); }); @@ -2143,7 +2147,9 @@ describe('provisionCloudflareResources — F4 create postcondition (round 4)', ( { ...emptyDesired, r2: { binding: 'STORAGE', bucketName: 'myapp-uploads' } }, { runWrangler: run, log: silentLog }, ), - ).rejects.toThrow(/reported success but .* holds no real R2 bucket id/s); + ).rejects.toThrow( + /reported success but .* does not hold the expected R2 bucket .*need a real bucket_name/s, + ); expect(readConfig().r2_buckets).toBeUndefined(); }); @@ -2160,7 +2166,9 @@ describe('provisionCloudflareResources — F4 create postcondition (round 4)', ( }, { runWrangler: run, log: silentLog, listHyperdriveConfigs: async () => [] }, ), - ).rejects.toThrow(/reported success but .* holds no real Hyperdrive config id/s); + ).rejects.toThrow( + /reported success but .* does not hold the expected Hyperdrive config .*need a real id/s, + ); expect(readConfig().hyperdrive).toBeUndefined(); }); }); @@ -2472,3 +2480,335 @@ describe('provisionCloudflareResources — F5 one D1 normalizer (round 5)', () = expect(d1[0].database_id).toBe('the-id'); }); }); + +describe('provisionCloudflareResources — transport account context (round 6 F1)', () => { + it('threads the reconciled account into EVERY wrangler call AND the Hyperdrive lister', async () => { + // Config pins the account → it is the reconciled effective account (config wins + // over the ambient CLOUDFLARE_ACCOUNT_ID). Every seam invocation must receive it. + setup(JSON.stringify({ account_id: 'reconciled-acct' })); + const seenRunAccounts: Array = []; + const seenListAccounts: Array = []; + const run: RunWrangler = (args, opts) => { + seenRunAccounts.push(opts.accountId); + const sub = args.join(' '); + if (/^d1 list --json/.test(sub)) { + return ok('[]'); + } + if (/^d1 create /.test(sub)) { + simulateUpdateConfig('d1_databases', { + binding: 'DB', + database_name: 'myapp-db', + database_id: 'db-id', + }); + return ok(); + } + if (/^kv namespace list/.test(sub)) { + return ok('[]'); + } + if (/^kv namespace create /.test(sub)) { + simulateUpdateConfig('kv_namespaces', { binding: 'KV', id: 'kv-id' }); + return ok(); + } + return ok(); + }; + await provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + d1: { binding: 'DB', name: 'myapp-db' }, + kvNamespaces: [{ binding: 'KV', title: 'myapp-kv' }], + // Empty connection string → only the existence check runs (no create), so the + // lister IS invoked and records the account it was called with. + hyperdrive: { binding: 'HYPERDRIVE', name: 'myapp-hd', connectionString: '' }, + }, + { + runWrangler: run, + log: silentLog, + listHyperdriveConfigs: async ({ accountId }) => { + seenListAccounts.push(accountId); + return []; + }, + }, + ); + expect(seenRunAccounts.length).toBeGreaterThan(0); + for (const acct of seenRunAccounts) { + expect(acct).toBe('reconciled-acct'); + } + expect(seenListAccounts).toEqual(['reconciled-acct']); + }); +}); + +describe('provisionCloudflareResources — boundary duplicate-member guard (round 6 F2)', () => { + it('fails closed at entry on a config with duplicate kv_namespaces members — NO wrangler/REST calls', async () => { + // jsonc-parser.parse() (used by readRootConfig) silently resolves a duplicate to + // the LAST member, so without the boundary guard a `kv namespace create` could + // orphan or a reuse be false. The SAME strict detector the writer uses runs at the + // provisioner boundary — before account persistence, discovery, or any remote work. + setup(`{ + "account_id": "acct", + "kv_namespaces": [{ "binding": "KV", "id": "aaa" }], + "kv_namespaces": [{ "binding": "KV", "id": "bbb" }] +}`); + let restCalled = false; + const { run, calls } = makeRunner([{ match: /.*/, reply: () => ok() }]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, kvNamespaces: [{ binding: 'KV', title: 'myapp-kv' }] }, + { + runWrangler: run, + log: silentLog, + listHyperdriveConfigs: async () => { + restCalled = true; + return []; + }, + }, + ), + ).rejects.toThrow(/duplicate key "kv_namespaces"/); + expect(calls).toEqual([]); + expect(restCalled).toBe(false); + }); +}); + +describe('provisionCloudflareResources — Hyperdrive list validation at the caller (round 6 F3)', () => { + const hdDesired: DesiredResourceSet = { + ...emptyDesired, + hyperdrive: { binding: 'HYPERDRIVE', name: 'myapp-hd', connectionString: 'postgres://x' }, + }; + + function provisionWithInjectedList(list: Array): Promise { + setup(JSON.stringify({ account_id: 'acct' })); + const { run } = makeRunner([{ match: /.*/, reply: () => ok() }]); + return provisionCloudflareResources(tmpDir, wranglerPath, hdDesired, { + runWrangler: run, + log: silentLog, + // The injected lister LIES about its return type — the caller-side validator must + // reject the malformed shape at runtime rather than adopt an empty/garbage id. + listHyperdriveConfigs: async () => list as never, + }); + } + + it('fails closed on an injected entry with an EMPTY id — never adopts', async () => { + await expect(provisionWithInjectedList([{ id: '', name: 'myapp-hd' }])).rejects.toThrow( + /malformed config entry/, + ); + expect(readConfig().hyperdrive).toBeUndefined(); + }); + + it('fails closed on a non-object member', async () => { + await expect(provisionWithInjectedList([null])).rejects.toThrow(/malformed config entry/); + expect(readConfig().hyperdrive).toBeUndefined(); + }); + + it('fails closed on a duplicate id across members', async () => { + await expect( + provisionWithInjectedList([ + { id: 'dup', name: 'other' }, + { id: 'dup', name: 'myapp-hd' }, + ]), + ).rejects.toThrow(/duplicate config id/); + expect(readConfig().hyperdrive).toBeUndefined(); + }); + + it('fails closed on 2 configs sharing the requested name (ambiguous)', async () => { + await expect( + provisionWithInjectedList([ + { id: 'x', name: 'myapp-hd' }, + { id: 'y', name: 'myapp-hd' }, + ]), + ).rejects.toThrow(/share that name/); + expect(readConfig().hyperdrive).toBeUndefined(); + }); +}); + +describe('provisionCloudflareResources — resource-specific create postcondition (round 6 F4)', () => { + it('R2: fails closed when create writes a DIFFERENT bucket_name than requested', async () => { + setup(JSON.stringify({ account_id: 'acct' })); + const { run } = makeRunner([ + { match: /^r2 bucket info /, reply: () => fail('', 'The specified bucket does not exist') }, + { + match: /^r2 bucket create /, + reply: () => { + // create "succeeds" but writes a DIFFERENT bucket than requested (a race / + // stale --update-config / wrong-account write). bucket_name IS the identity. + simulateUpdateConfig('r2_buckets', { binding: 'STORAGE', bucket_name: 'other-bucket' }); + return ok(); + }, + }, + ]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, r2: { binding: 'STORAGE', bucketName: 'myapp-uploads' } }, + { runWrangler: run, log: silentLog }, + ), + ).rejects.toThrow(/does not hold the expected R2 bucket .*bucket_name === "myapp-uploads"/s); + }); + + it('D1: fails closed when create writes a real id but a MISMATCHED database_name', async () => { + setup(JSON.stringify({ account_id: 'acct' })); + const { run } = makeRunner([ + { match: /^d1 list --json/, reply: () => ok('[]') }, + { + match: /^d1 create /, + reply: () => { + simulateUpdateConfig('d1_databases', { + binding: 'DB', + database_name: 'other-db', + database_id: 'real-id', + }); + return ok(); + }, + }, + ]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' } }, + { runWrangler: run, log: silentLog }, + ), + ).rejects.toThrow(/does not hold the expected D1 database .*database_name === "myapp-db"/s); + }); +}); + +describe('provisionCloudflareResources — cross-process Hyperdrive lock (round 6 F6)', () => { + const lockPath = `${wranglerPath}.void-provision.lock`; + const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + + it('two concurrent Hyperdrive provisions race — exactly ONE create, the other reuses', async () => { + setup( + JSON.stringify({ account_id: 'acct', hyperdrive: [{ binding: 'HYPERDRIVE', id: 'local' }] }), + ); + let createCalls = 0; + const makeProv = (): Promise<{ resources: Array<{ kind: string; action: string }> }> => { + const run: RunWrangler = (args) => { + if (isCall(args, 'hyperdrive', 'create')) { + createCalls++; + simulateUpdateConfig('hyperdrive', { binding: 'HYPERDRIVE', id: `hd-${createCalls}` }); + return ok(); + } + return ok(); + }; + return provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + hyperdrive: { binding: 'HYPERDRIVE', name: 'myapp-hd', connectionString: 'postgres://x' }, + }, + { + runWrangler: run, + log: silentLog, + // Barrier: the winner holds the lock while LISTING (40ms), so the loser spins + // on the lock — proving the LOCK (not accidental sequencing) serializes them. + listHyperdriveConfigs: async () => { + await delay(40); + return []; + }, + }, + ) as Promise<{ resources: Array<{ kind: string; action: string }> }>; + }; + const [a, b] = await Promise.all([makeProv(), makeProv()]); + const actions = [...a.resources, ...b.resources] + .filter((r) => r.kind === 'hyperdrive') + .map((r) => r.action) + .sort(); + // Exactly one create; the second read-first (inside the lock) reused the first's id. + expect(createCalls).toBe(1); + expect(actions).toEqual(['created', 'reused']); + // Lock always released — no leak. + expect(existsSync(lockPath)).toBe(false); + }); + + it('breaks a STALE lock (old timestamp) and proceeds', async () => { + setup( + JSON.stringify({ account_id: 'acct', hyperdrive: [{ binding: 'HYPERDRIVE', id: 'local' }] }), + ); + writeFileSync( + lockPath, + JSON.stringify({ pid: process.pid, timestamp: Date.now() - 10 * 60_000 }), + ); + const { run } = makeRunner([ + { + match: /^hyperdrive create /, + reply: () => { + simulateUpdateConfig('hyperdrive', { binding: 'HYPERDRIVE', id: 'hd-real' }); + return ok(); + }, + }, + ]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + hyperdrive: { binding: 'HYPERDRIVE', name: 'myapp-hd', connectionString: 'postgres://x' }, + }, + { runWrangler: run, log: silentLog, listHyperdriveConfigs: async () => [] }, + ); + expect(result.resources.find((r) => r.kind === 'hyperdrive')?.action).toBe('created'); + expect(existsSync(lockPath)).toBe(false); + }); + + it('breaks a STALE lock (dead pid) and proceeds', async () => { + setup( + JSON.stringify({ account_id: 'acct', hyperdrive: [{ binding: 'HYPERDRIVE', id: 'local' }] }), + ); + // Find a PID that is definitely NOT alive (process.kill throws ESRCH). + let deadPid = 999999; + for (let pid = 999999; pid > 1; pid--) { + try { + process.kill(pid, 0); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') { + deadPid = pid; + break; + } + } + } + writeFileSync(lockPath, JSON.stringify({ pid: deadPid, timestamp: Date.now() })); + const { run } = makeRunner([ + { + match: /^hyperdrive create /, + reply: () => { + simulateUpdateConfig('hyperdrive', { binding: 'HYPERDRIVE', id: 'hd-real' }); + return ok(); + }, + }, + ]); + const result = await provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + hyperdrive: { binding: 'HYPERDRIVE', name: 'myapp-hd', connectionString: 'postgres://x' }, + }, + { runWrangler: run, log: silentLog, listHyperdriveConfigs: async () => [] }, + ); + expect(result.resources.find((r) => r.kind === 'hyperdrive')?.action).toBe('created'); + expect(existsSync(lockPath)).toBe(false); + }); + + it('always releases the lock even when the provision THROWS (no leak)', async () => { + setup( + JSON.stringify({ account_id: 'acct', hyperdrive: [{ binding: 'HYPERDRIVE', id: 'local' }] }), + ); + const { run } = makeRunner([{ match: /^hyperdrive create /, reply: () => fail('', 'boom') }]); + await expect( + provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + hyperdrive: { binding: 'HYPERDRIVE', name: 'myapp-hd', connectionString: 'postgres://x' }, + }, + { runWrangler: run, log: silentLog, listHyperdriveConfigs: async () => [] }, + ), + ).rejects.toThrow(/hyperdrive create myapp-hd.*failed/s); + expect(existsSync(lockPath)).toBe(false); + }); +}); From 769cc347a19038e55f819c73e3ff0a22545001d7 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 16 Jul 2026 20:05:11 +0800 Subject: [PATCH 025/128] fix(provision): close codex round-7 F1/F2/F3 in the CF provisioner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F1 — hold ONE cross-process lock for the WHOLE provision (acquired before the first config read, released in `finally`), so account reconciliation + persist + all discovery/remote/writes run under it; a racing first-run serializes and the second fails closed on the persisted-account mismatch. Body extracted into `provisionCloudflareResourcesLocked`. F2 — replace the hand-rolled O_EXCL lockfile (empty-file window, age-only expiry, check-then-unlink stale race, ownerless release) with `proper-lockfile`: atomic mkdir mutex on a dedicated adjacent marker file, mtime-refresh so a live owner never expires, stale-compromise detection, ownership-scoped release, and an `onCompromised` handler that fails closed (re-throws). F3 — treat the Hyperdrive existence-check result as UNKNOWN until proven a real array: `Array.isArray` gate BEFORE marking existence known, `selectHyperdriveMatch` on the proven array only. A runtime null/undefined/non-array leaves existence unknown and fails closed — no blind `hyperdrive create`. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/package.json | 2 + packages/void/src/cli/provision.ts | 384 +++++++++++----------- packages/void/test/unit/provision.test.ts | 186 ++++++++--- pnpm-lock.yaml | 114 ++++++- 4 files changed, 423 insertions(+), 263 deletions(-) diff --git a/packages/void/package.json b/packages/void/package.json index d00e86611..d11994dc0 100644 --- a/packages/void/package.json +++ b/packages/void/package.json @@ -332,6 +332,7 @@ "ignore": "^7.0.5", "jsonc-parser": "3.3.1", "pg": "8.22.0", + "proper-lockfile": "^4.1.2", "wrangler": "catalog:" }, "devDependencies": { @@ -340,6 +341,7 @@ "@types/better-sqlite3": "^7.6.13", "@types/node": "catalog:", "@types/pg": "^8.20.0", + "@types/proper-lockfile": "^4.1.4", "@typescript/native-preview": "catalog:", "arktype": "^2.2.2", "estree-walker": "^3.0.3", diff --git a/packages/void/src/cli/provision.ts b/packages/void/src/cli/provision.ts index d162b403b..e8601b639 100644 --- a/packages/void/src/cli/provision.ts +++ b/packages/void/src/cli/provision.ts @@ -1,7 +1,8 @@ import { spawnSync } from 'node:child_process'; -import { closeSync, existsSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { parse, type ParseError } from 'jsonc-parser'; import { resolve } from 'pathe'; +import lockfile from 'proper-lockfile'; import { voidLog, voidWarn } from '../log.ts'; import { resolveWranglerArgv } from './preset.ts'; import { @@ -445,127 +446,86 @@ function selectHyperdriveMatch( return named[0]; } -// --- F6: cross-process provision lock ------------------------------------------ -// Hyperdrive is an unguarded list→create and its names are NOT account-unique, so two -// concurrent provisions could both see "no match" and both create — a permanent -// orphaned duplicate that read-first then hides forever. Serialize the list→create -// across processes with a lockfile keyed on the resolved config path (O_EXCL create). -// A second concurrent run blocks until the first releases, then its read-first (done -// INSIDE the lock) sees the id the first wrote and SKIPS. +// --- F1/F2: whole-provision cross-process lock (proper-lockfile) ---------------- +// TWO independent races must be serialized across processes, so ONE lock is held for +// the WHOLE provision (acquired before the first config read, released in `finally`): +// 1. Account reconciliation (F1). A first-run provision READS → RECONCILES → PERSISTS +// one effective `account_id`. Two first-run processes for accounts A and B could +// otherwise both see no `account_id`, overwrite each other, and then write an +// A-side result into a now-B-pinned config. Under the lock the second process +// re-reads the persisted account and FAILS CLOSED on mismatch (or reuses). +// 2. Hyperdrive list→create (round 6). Hyperdrive is unguarded and its names are NOT +// account-unique, so two concurrent provisions could both see "no match" and both +// create a permanent orphaned duplicate. Under the lock the second run's +// read-first sees the id the first wrote and SKIPS. +// The hand-rolled O_EXCL lock this replaces had classic protocol bugs (a freshly +// created file is visible before its metadata is written; age-only expiry ignoring a +// live owner; a check-then-unlink stale-takeover race; an ownerless release that could +// remove a successor's lock). `proper-lockfile` does atomic `mkdir`-based acquisition, +// mtime-refresh so a live owner never expires by age, stale-compromise detection, and +// ownership-scoped release. interface ProvisionLock { - release: () => void; + release: () => Promise; } /** - * A lock older than this — or whose holder PID is dead — is STALE and safe to break, so - * a crashed provision cannot wedge every future one. Generous vs. a real provision's - * duration. + * A lock whose owner has not refreshed it within this window is treated as abandoned + * (crashed) and may be compromised/stolen, so a crashed provision cannot wedge every + * future one. A LIVE owner refreshes the mutex mtime every {@link LOCK_UPDATE_MS} + * (strictly less than this), so it never expires by age. Generous vs. a real + * provision's duration. */ const LOCK_STALE_MS = 120_000; -/** How long to wait for a held (non-stale) lock before giving up. */ -const LOCK_ACQUIRE_TIMEOUT_MS = 60_000; -/** Poll interval while waiting for a held lock. */ -const LOCK_POLL_MS = 50; - -const lockDelay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); - +/** Refresh interval keeping a live owner's lock fresh (must be < {@link LOCK_STALE_MS}). */ +const LOCK_UPDATE_MS = 20_000; /** - * True when a PID is still alive. `process.kill(pid, 0)` sends NO signal but throws - * `ESRCH` for a dead process and `EPERM` for a live one we may not signal (still alive). + * Backoff budget while waiting for a held (non-stale) lock. Polls with jittered backoff + * for roughly a minute before giving up, so a concurrent provision blocks (not spins) + * until the holder releases, then proceeds. */ -function isProcessAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (error) { - return (error as NodeJS.ErrnoException).code === 'EPERM'; - } -} +const LOCK_RETRIES = { retries: 100, factor: 1.5, minTimeout: 200, maxTimeout: 2_000 } as const; /** - * Decide whether a currently-held lockfile is STALE (safe to break): a corrupt/malformed - * lock, one older than {@link LOCK_STALE_MS}, or one whose holder PID is dead. A lock - * that VANISHED (already released between the EEXIST and this read) is NOT stale — the - * caller's retry re-acquires it cleanly. + * Acquire the cross-process provision lock over the resolved config path via + * `proper-lockfile` (an atomic `mkdir`-based mutex at `.void-provision.lock.lock`). + * `proper-lockfile` requires the locked pathname to EXIST, so we lock a DEDICATED + * adjacent marker file (`.void-provision.lock`), creating it if absent — + * never the real config, so the writer's missing-vs-empty config semantics stay + * untouched. Returns a handle whose `release()` drops the mutex; ALWAYS call it in a + * `finally` so an interruption never leaks the lock. + * + * `onCompromised` FAILS CLOSED (re-throws): if the lock is ever detected stolen (its + * mtime changed, or the mutex vanished under a live owner), provisioning must NEVER + * continue — a compromised lock means another process may be racing the same config. */ -function isStaleLock(lockPath: string): boolean { - let raw: string; - try { - raw = readFileSync(lockPath, 'utf-8'); - } catch { - return false; - } - let data: unknown; - try { - data = JSON.parse(raw); - } catch { - return true; - } - if (!isRecord(data) || typeof data.pid !== 'number' || typeof data.timestamp !== 'number') { - return true; - } - if (Date.now() - data.timestamp > LOCK_STALE_MS) { - return true; +export async function acquireProvisionLock(configPath: string): Promise { + const lockTarget = `${configPath}.void-provision.lock`; + if (!existsSync(lockTarget)) { + writeFileSync(lockTarget, ''); } - return !isProcessAlive(data.pid); -} - -/** - * Acquire the cross-process provision lock via an O_EXCL (`'wx'`) create holding - * `{ pid, timestamp }`. Breaks a STALE lock (dead PID / too old), waits out a live one - * up to {@link LOCK_ACQUIRE_TIMEOUT_MS}, and returns a handle whose `release()` removes - * the lockfile. ALWAYS call `release()` in a `finally` so an interruption never leaks - * the lock. - */ -async function acquireProvisionLock(configPath: string): Promise { - const lockPath = `${configPath}.void-provision.lock`; - const deadline = Date.now() + LOCK_ACQUIRE_TIMEOUT_MS; - for (;;) { - try { - const fd = openSync(lockPath, 'wx'); - try { - writeFileSync(fd, JSON.stringify({ pid: process.pid, timestamp: Date.now() })); - } finally { - closeSync(fd); - } - let released = false; - return { - release: () => { - if (released) { - return; - } - released = true; - try { - rmSync(lockPath, { force: true }); - } catch { - // best-effort: a stale-sweep or manual cleanup may have removed it already - } - }, - }; - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { - throw error; - } - // Held by someone. Break it if stale; otherwise wait and retry until the deadline. - if (isStaleLock(lockPath)) { - try { - rmSync(lockPath, { force: true }); - } catch { - // another racer may have broken it first — just retry the O_EXCL create - } - continue; + const release = await lockfile.lock(lockTarget, { + stale: LOCK_STALE_MS, + update: LOCK_UPDATE_MS, + retries: LOCK_RETRIES, + onCompromised: (error) => { + throw error; + }, + }); + let released = false; + return { + release: async () => { + if (released) { + return; } - if (Date.now() >= deadline) { - throw new Error( - `provision: timed out after ${LOCK_ACQUIRE_TIMEOUT_MS}ms waiting for the provision lock ` + - `at "${lockPath}". Another provision is running for this config; retry once it finishes, ` + - `or remove a stale lockfile.`, - ); + released = true; + try { + await release(); + } catch { + // best-effort: a stale-compromise sweep may have already dropped the mutex } - await lockDelay(LOCK_POLL_MS); - } - } + }, + }; } /** @@ -1013,13 +973,7 @@ export async function provisionCloudflareResources( root: string, rootWranglerPath: string, desired: DesiredResourceSet, - opts?: { - runWrangler?: RunWrangler; - accountId?: string; - apiToken?: string; - listHyperdriveConfigs?: ListHyperdriveConfigs; - log?: ProvisionLogger; - }, + opts?: ProvisionOptions, ): Promise { await Promise.resolve(); // ONE ABSOLUTE config path threaded through read-first, wrangler `-c`, AND the @@ -1031,6 +985,42 @@ export async function provisionCloudflareResources( // creates against another (orphan). An already-absolute path is returned as-is. const configPath = resolve(root, rootWranglerPath); + // F1/F2 — hold ONE cross-process lock for the WHOLE provision: acquire it BEFORE + // the first config read (so account reconciliation + persistence + all discovery, + // remote work, and config writes happen against the state the lock protects), and + // ALWAYS release it in `finally` (an interruption must never leak the lock). The + // reconciliation + duplicate-member guard + resource work all run UNDER the lock in + // `provisionCloudflareResourcesLocked`, so a racing first-run serializes: the second + // process re-reads the persisted account/ids and fails closed (or reuses). + const lock = await acquireProvisionLock(configPath); + try { + return await provisionCloudflareResourcesLocked(root, configPath, desired, opts); + } finally { + await lock.release(); + } +} + +/** Injectable seams + settings for {@link provisionCloudflareResources}. */ +export interface ProvisionOptions { + runWrangler?: RunWrangler; + accountId?: string; + apiToken?: string; + listHyperdriveConfigs?: ListHyperdriveConfigs; + log?: ProvisionLogger; +} + +/** + * The provision body, run entirely UNDER the whole-provision lock (see + * {@link provisionCloudflareResources}). `configPath` is the ONE resolved absolute + * config path the lock is keyed on — threaded through read-first, wrangler `-c`, and + * the writer so reads/writes can never hit different files. + */ +async function provisionCloudflareResourcesLocked( + root: string, + configPath: string, + desired: DesiredResourceSet, + opts?: ProvisionOptions, +): Promise { // --- F2: strict duplicate-member guard at the PROVISIONER BOUNDARY -------- // `jsonc-parser.parse()` (used by `readRootConfig`) does NOT flag duplicate object // members, and the Task 3 writer's duplicate-member guard only runs when the writer @@ -1463,90 +1453,100 @@ export async function provisionCloudflareResources( // attempted BEFORE the connection-string gate (it needs no connection string). if (desired.hyperdrive) { const { binding, name, connectionString } = desired.hyperdrive; - // F6 — serialize the unguarded list→create across processes with a cross-process - // lock keyed on the resolved config path. The read-first check is done INSIDE the - // lock so a second concurrent provision, after acquiring the lock, sees the id the - // first wrote and SKIPS (no duplicate). Always released in `finally`, incl. on any - // throw below (a leaked lock would wedge the next run until the stale timeout). - const lock = await acquireProvisionLock(configPath); - try { - const entry = findBindingEntry(readRootConfig(configPath).hyperdrive, binding); - if (entry && isRealValue(entry.id)) { - resources.push({ kind: 'hyperdrive', binding, name, action: 'reused', id: entry.id }); - } else { - const lister = - opts?.listHyperdriveConfigs ?? createDefaultListHyperdriveConfigs(opts?.apiToken); - let configs: Array | undefined; - let existenceUnknown = true; - if (lister) { - try { - // F1 — always invoke the lister WITH the reconciled account, so an injected - // lister can never query a DIFFERENT account than the config is pinned to. - configs = await lister({ accountId: transportAccountId }); - existenceUnknown = false; - } catch (error) { - log.warn(`Hyperdrive existence check for ${name} failed: ${(error as Error).message}`); - existenceUnknown = true; - } - } - // F3 — apply the ONE strict Hyperdrive-list validator at the CALLER, AFTER the - // lister returns (default OR injected). It rejects malformed/empty/duplicate - // entries and requires EXACTLY 0 or 1 exact-name match (>1 → fail closed) BEFORE - // matching or writing, so no injected list can be adopted with an empty/garbage id. - const existing = configs ? selectHyperdriveMatch(configs, name, configPath) : undefined; - if (existing) { - log.info(`adopted existing Hyperdrive config ${name} (${existing.id})`); - write({ hyperdrive: { binding, id: existing.id } }); - resources.push({ kind: 'hyperdrive', binding, name, action: 'adopted', id: existing.id }); - } else if (!connectionString) { - // No connection string → we can never CREATE, so there is no duplicate - // risk whether or not existence is known: skip (structured, never prompts). - const reason = 'no connection string provided'; - log.warn(`skipping Hyperdrive config ${name}: ${reason}`); - resources.push({ kind: 'hyperdrive', binding, name, action: 'skipped', reason }); - } else if (existenceUnknown) { - // A connection string is present (we WOULD create) but existence is - // unverifiable → FAIL CLOSED. Blindly creating duplicates a same-named config. - throw new Error( - `provision: cannot verify whether Hyperdrive config ${JSON.stringify(name)} already ` + - `exists (no existence check available — provide an accountId + API token, or inject a ` + - `listHyperdriveConfigs seam). "wrangler hyperdrive create" silently duplicates a ` + - `same-named config, so provisioning refuses to create it blindly.`, - ); - } else { - // Confirmed absent + connection string present → create. - const created = runWrangler([ - 'hyperdrive', - 'create', - name, - `--connection-string=${connectionString}`, - '--binding', - binding, - '--update-config', - ...wranglerConfigArgs(configPath), - ]); - if (!isCleanSuccess(created)) { + // The whole-provision lock (F1/F2) already serializes this unguarded list→create + // across processes: the read-first check below runs UNDER that lock, so a second + // concurrent provision sees the id the first wrote and SKIPS (no duplicate). + const entry = findBindingEntry(readRootConfig(configPath).hyperdrive, binding); + if (entry && isRealValue(entry.id)) { + resources.push({ kind: 'hyperdrive', binding, name, action: 'reused', id: entry.id }); + } else { + const lister = + opts?.listHyperdriveConfigs ?? createDefaultListHyperdriveConfigs(opts?.apiToken); + // F3 — treat the seam result as UNKNOWN until PROVEN a real array. The OUTER + // result is validated (`Array.isArray`) BEFORE existence is marked known, so a + // runtime `null`/`undefined`/non-array is NEVER coerced to "empty" (confirmed + // absence) — which would authorize a blind create. A malformed outer result and a + // failed async lister BOTH leave existence UNKNOWN → fail closed if we WOULD create. + let validatedConfigs: Array | undefined; + let existenceUnknown = true; + if (lister) { + try { + // F1 — always invoke the lister WITH the reconciled account, so an injected + // lister can never query a DIFFERENT account than the config is pinned to. + const result = await lister({ accountId: transportAccountId }); + if (!Array.isArray(result)) { throw new Error( - `provision: "wrangler hyperdrive create ${name}" failed: ${created.stderr || created.stdout}`, + `the existence check returned a ${result === null ? 'null' : typeof result} result ` + + `instead of an array`, ); } - // F4 postcondition: confirm --update-config wrote a REAL Hyperdrive id. - assertCreateWroteRealId( - configPath, - { - field: 'hyperdrive', - idField: 'id', - resource: 'Hyperdrive config', - command: 'wrangler hyperdrive create', - }, - binding, - name, + validatedConfigs = result; + existenceUnknown = false; + } catch (error) { + log.warn(`Hyperdrive existence check for ${name} failed: ${(error as Error).message}`); + existenceUnknown = true; + validatedConfigs = undefined; + } + } + // F3 — the ONE strict per-entry validator runs OUTSIDE the try, on a PROVEN array + // only, so its malformed-member / duplicate-id / ambiguous-name throws stay HARD + // failures (never swallowed into "unknown"). It requires EXACTLY 0 or 1 exact-name + // match (>1 → fail closed) BEFORE matching or writing, so no injected list can be + // adopted with an empty/garbage id. + const existing = + validatedConfigs !== undefined + ? selectHyperdriveMatch(validatedConfigs, name, configPath) + : undefined; + if (existing) { + log.info(`adopted existing Hyperdrive config ${name} (${existing.id})`); + write({ hyperdrive: { binding, id: existing.id } }); + resources.push({ kind: 'hyperdrive', binding, name, action: 'adopted', id: existing.id }); + } else if (!connectionString) { + // No connection string → we can never CREATE, so there is no duplicate + // risk whether or not existence is known: skip (structured, never prompts). + const reason = 'no connection string provided'; + log.warn(`skipping Hyperdrive config ${name}: ${reason}`); + resources.push({ kind: 'hyperdrive', binding, name, action: 'skipped', reason }); + } else if (existenceUnknown) { + // A connection string is present (we WOULD create) but existence is + // unverifiable → FAIL CLOSED. Blindly creating duplicates a same-named config. + throw new Error( + `provision: cannot verify whether Hyperdrive config ${JSON.stringify(name)} already ` + + `exists (no existence check available — provide an accountId + API token, or inject a ` + + `listHyperdriveConfigs seam). "wrangler hyperdrive create" silently duplicates a ` + + `same-named config, so provisioning refuses to create it blindly.`, + ); + } else { + // Confirmed absent + connection string present → create. + const created = runWrangler([ + 'hyperdrive', + 'create', + name, + `--connection-string=${connectionString}`, + '--binding', + binding, + '--update-config', + ...wranglerConfigArgs(configPath), + ]); + if (!isCleanSuccess(created)) { + throw new Error( + `provision: "wrangler hyperdrive create ${name}" failed: ${created.stderr || created.stdout}`, ); - resources.push({ kind: 'hyperdrive', binding, name, action: 'created' }); } + // F4 postcondition: confirm --update-config wrote a REAL Hyperdrive id. + assertCreateWroteRealId( + configPath, + { + field: 'hyperdrive', + idField: 'id', + resource: 'Hyperdrive config', + command: 'wrangler hyperdrive create', + }, + binding, + name, + ); + resources.push({ kind: 'hyperdrive', binding, name, action: 'created' }); } - } finally { - lock.release(); } } diff --git a/packages/void/test/unit/provision.test.ts b/packages/void/test/unit/provision.test.ts index 9d0d2dba6..b88fc84ee 100644 --- a/packages/void/test/unit/provision.test.ts +++ b/packages/void/test/unit/provision.test.ts @@ -1,8 +1,9 @@ -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; import { parse as parseJsoncRaw } from 'jsonc-parser'; import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'; import { + acquireProvisionLock, normalizeRunWranglerResult, provisionCloudflareResources, provisionEnv, @@ -2621,6 +2622,31 @@ describe('provisionCloudflareResources — Hyperdrive list validation at the cal ).rejects.toThrow(/share that name/); expect(readConfig().hyperdrive).toBeUndefined(); }); + + // F3 — a runtime null/undefined/non-array OUTER result must be treated as UNKNOWN + // (never coerced to "empty" = confirmed absence), so it fails closed BEFORE any + // create instead of authorizing a blind `hyperdrive create` on a same-named config. + for (const [label, bad] of [ + ['null', null], + ['undefined', undefined], + ['a non-array object', { configs: [] }], + ['a string', 'nope'], + ] as const) { + it(`fails closed (no create) when the lister returns ${label}`, async () => { + setup(JSON.stringify({ account_id: 'acct' })); + const { run, calls } = makeRunner([{ match: /.*/, reply: () => ok() }]); + await expect( + provisionCloudflareResources(tmpDir, wranglerPath, hdDesired, { + runWrangler: run, + log: silentLog, + listHyperdriveConfigs: async () => bad as never, + }), + ).rejects.toThrow(/cannot verify whether Hyperdrive config "myapp-hd"/); + // No blind create, and nothing written to the config. + expect(calls.some((c) => isCall(c, 'hyperdrive', 'create'))).toBe(false); + expect(readConfig().hyperdrive).toBeUndefined(); + }); + } }); describe('provisionCloudflareResources — resource-specific create postcondition (round 6 F4)', () => { @@ -2675,11 +2701,15 @@ describe('provisionCloudflareResources — resource-specific create postconditio }); }); -describe('provisionCloudflareResources — cross-process Hyperdrive lock (round 6 F6)', () => { - const lockPath = `${wranglerPath}.void-provision.lock`; +describe('provisionCloudflareResources — whole-provision cross-process lock (F1/F2, proper-lockfile)', () => { + // proper-lockfile locks a dedicated adjacent MARKER file (persists between runs) and + // implements the mutex as `${marker}.lock` DIRECTORY (removed on release). "No leak" + // therefore means the MUTEX dir is gone (not the marker file). + const lockMarker = `${wranglerPath}.void-provision.lock`; + const lockMutex = `${lockMarker}.lock`; const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); - it('two concurrent Hyperdrive provisions race — exactly ONE create, the other reuses', async () => { + it('two concurrent Hyperdrive provisions race — exactly ONE create, the other reuses, mutex released', async () => { setup( JSON.stringify({ account_id: 'acct', hyperdrive: [{ binding: 'HYPERDRIVE', id: 'local' }] }), ); @@ -2703,7 +2733,7 @@ describe('provisionCloudflareResources — cross-process Hyperdrive lock (round { runWrangler: run, log: silentLog, - // Barrier: the winner holds the lock while LISTING (40ms), so the loser spins + // Barrier: the winner holds the lock while LISTING (40ms), so the loser blocks // on the lock — proving the LOCK (not accidental sequencing) serializes them. listHyperdriveConfigs: async () => { await delay(40); @@ -2720,18 +2750,20 @@ describe('provisionCloudflareResources — cross-process Hyperdrive lock (round // Exactly one create; the second read-first (inside the lock) reused the first's id. expect(createCalls).toBe(1); expect(actions).toEqual(['created', 'reused']); - // Lock always released — no leak. - expect(existsSync(lockPath)).toBe(false); + // Mutex always released — no leak. + expect(existsSync(lockMutex)).toBe(false); }); - it('breaks a STALE lock (old timestamp) and proceeds', async () => { + it('recovers a STALE/abandoned lock (old mtime, crashed owner) and proceeds', async () => { setup( JSON.stringify({ account_id: 'acct', hyperdrive: [{ binding: 'HYPERDRIVE', id: 'local' }] }), ); - writeFileSync( - lockPath, - JSON.stringify({ pid: process.pid, timestamp: Date.now() - 10 * 60_000 }), - ); + // Simulate a crashed owner's abandoned mutex: the marker + a mutex dir whose mtime + // is well past the 120s stale window, so proper-lockfile compromises + steals it. + writeFileSync(lockMarker, ''); + mkdirSync(lockMutex); + const old = new Date(Date.now() - 300_000); + utimesSync(lockMutex, old, old); const { run } = makeRunner([ { match: /^hyperdrive create /, @@ -2751,46 +2783,7 @@ describe('provisionCloudflareResources — cross-process Hyperdrive lock (round { runWrangler: run, log: silentLog, listHyperdriveConfigs: async () => [] }, ); expect(result.resources.find((r) => r.kind === 'hyperdrive')?.action).toBe('created'); - expect(existsSync(lockPath)).toBe(false); - }); - - it('breaks a STALE lock (dead pid) and proceeds', async () => { - setup( - JSON.stringify({ account_id: 'acct', hyperdrive: [{ binding: 'HYPERDRIVE', id: 'local' }] }), - ); - // Find a PID that is definitely NOT alive (process.kill throws ESRCH). - let deadPid = 999999; - for (let pid = 999999; pid > 1; pid--) { - try { - process.kill(pid, 0); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ESRCH') { - deadPid = pid; - break; - } - } - } - writeFileSync(lockPath, JSON.stringify({ pid: deadPid, timestamp: Date.now() })); - const { run } = makeRunner([ - { - match: /^hyperdrive create /, - reply: () => { - simulateUpdateConfig('hyperdrive', { binding: 'HYPERDRIVE', id: 'hd-real' }); - return ok(); - }, - }, - ]); - const result = await provisionCloudflareResources( - tmpDir, - wranglerPath, - { - ...emptyDesired, - hyperdrive: { binding: 'HYPERDRIVE', name: 'myapp-hd', connectionString: 'postgres://x' }, - }, - { runWrangler: run, log: silentLog, listHyperdriveConfigs: async () => [] }, - ); - expect(result.resources.find((r) => r.kind === 'hyperdrive')?.action).toBe('created'); - expect(existsSync(lockPath)).toBe(false); + expect(existsSync(lockMutex)).toBe(false); }); it('always releases the lock even when the provision THROWS (no leak)', async () => { @@ -2809,6 +2802,93 @@ describe('provisionCloudflareResources — cross-process Hyperdrive lock (round { runWrangler: run, log: silentLog, listHyperdriveConfigs: async () => [] }, ), ).rejects.toThrow(/hyperdrive create myapp-hd.*failed/s); - expect(existsSync(lockPath)).toBe(false); + expect(existsSync(lockMutex)).toBe(false); + }); + + it('acquireProvisionLock serializes — a second acquire waits until the first releases, then proceeds; release always frees it', async () => { + setup(JSON.stringify({ name: 'app' })); + const first = await acquireProvisionLock(wranglerPath); + expect(existsSync(lockMutex)).toBe(true); + let secondAcquired = false; + const secondPromise = acquireProvisionLock(wranglerPath).then((lock) => { + secondAcquired = true; + return lock; + }); + // While the first holds the lock the second cannot acquire (it retries/blocks). + await delay(60); + expect(secondAcquired).toBe(false); + await first.release(); + const second = await secondPromise; + expect(secondAcquired).toBe(true); + // Release is idempotent and always frees the mutex. + await second.release(); + await second.release(); + expect(existsSync(lockMutex)).toBe(false); + }); + + it('F1 — two racing FIRST-RUNS (accounts A and B) serialize; the second fails closed on mismatch, no cross-account write', async () => { + // Neither run has a config account_id at start. The whole-provision lock forces them + // to serialize: the winner reads no account, persists its own, and provisions; the + // loser — under the lock — re-reads the persisted account and fails closed on the + // A≠B mismatch BEFORE any create, so it never rebinds the winner's resources. + setup(JSON.stringify({ name: 'app' })); + const savedAmbient = process.env.CLOUDFLARE_ACCOUNT_ID; + delete process.env.CLOUDFLARE_ACCOUNT_ID; // force reconciliation via opts.accountId only + try { + const makeProv = (account: string): Promise => { + const run: RunWrangler = (args) => { + if (isCall(args, 'd1', 'list')) { + return ok('[]'); + } + if (isCall(args, 'd1', 'create')) { + simulateUpdateConfig('d1_databases', { + binding: 'DB', + database_name: 'myapp-db', + database_id: `db-${account}`, + }); + return ok(); + } + return ok(); + }; + return provisionCloudflareResources( + tmpDir, + wranglerPath, + { + ...emptyDesired, + // A slow lister keeps the winner holding the lock long enough that the loser + // is forced to wait on it (serialization, not accidental sequencing). + d1: { binding: 'DB', name: 'myapp-db' }, + }, + { + runWrangler: run, + accountId: account, + log: silentLog, + listHyperdriveConfigs: async () => [], + }, + ); + }; + const results = await Promise.allSettled([makeProv('acct-A'), makeProv('acct-B')]); + const fulfilled = results.filter((r) => r.status === 'fulfilled'); + const rejected = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected'); + // Exactly one wins; the other fails closed on the account mismatch. + expect(fulfilled.length).toBe(1); + expect(rejected.length).toBe(1); + expect(String(rejected[0].reason)).toMatch(/account mismatch/); + // The config is pinned to the WINNER, with the winner's D1 id — no cross-account + // overwrite (both never wrote; the loser never ran a wrangler create). + const cfg = readConfig(); + const winner = cfg.account_id as string; + expect(['acct-A', 'acct-B']).toContain(winner); + const d1 = (cfg.d1_databases as Array>)[0]; + expect(d1.database_id).toBe(`db-${winner}`); + // Mutex released — no leak. + expect(existsSync(lockMutex)).toBe(false); + } finally { + if (savedAmbient === undefined) { + delete process.env.CLOUDFLARE_ACCOUNT_ID; + } else { + process.env.CLOUDFLARE_ACCOUNT_ID = savedAmbient; + } + } }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c11a983a..1fb9b4580 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -60,7 +60,7 @@ importers: version: 0.2.2(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(svelte@5.56.4)(terser@5.48.0)(tsx@4.23.0)(typescript@6.0.3)(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0))(yaml@2.9.0) vitest: specifier: ^4.1.9 - version: 4.1.9(@types/node@26.1.0)(@vitest/browser-preview@4.1.9(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0))(vitest@4.1.9))(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0)) + version: 4.1.9(@types/node@26.1.0)(@vitest/browser-preview@4.1.9)(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0)) docs: devDependencies: @@ -302,6 +302,9 @@ importers: pg: specifier: 8.22.0 version: 8.22.0 + proper-lockfile: + specifier: ^4.1.2 + version: 4.1.2 vite: specifier: npm:@voidzero-dev/vite-plus-core@0.2.2 version: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(typescript@6.0.3)(yaml@2.9.0)' @@ -324,6 +327,9 @@ importers: '@types/pg': specifier: ^8.20.0 version: 8.20.0 + '@types/proper-lockfile': + specifier: ^4.1.4 + version: 4.1.4 '@typescript/native-preview': specifier: 'catalog:' version: 7.0.0-dev.20260507.1 @@ -1922,11 +1928,11 @@ packages: '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild-kit/esm-loader@2.6.5': resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild/aix-ppc64@0.27.7': resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} @@ -5145,6 +5151,9 @@ packages: '@types/pg@8.20.0': resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + '@types/proper-lockfile@4.1.4': + resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -5156,6 +5165,9 @@ packages: '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + '@types/retry@0.12.5': + resolution: {integrity: sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -13107,6 +13119,10 @@ snapshots: pg-protocol: 1.15.0 pg-types: 2.2.0 + '@types/proper-lockfile@4.1.4': + dependencies: + '@types/retry': 0.12.5 + '@types/react-dom@19.2.3(@types/react@19.2.17)': dependencies: '@types/react': 19.2.17 @@ -13117,6 +13133,8 @@ snapshots: '@types/resolve@1.20.2': {} + '@types/retry@0.12.5': {} + '@types/trusted-types@2.0.7': {} '@types/unist@3.0.3': {} @@ -13239,6 +13257,18 @@ snapshots: vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(typescript@6.0.3)(yaml@2.9.0)' vue: 3.5.39(typescript@6.0.3) + '@vitest/browser-preview@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.9(@types/node@26.1.0)(@vitest/browser-preview@4.1.9)(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0)))': + dependencies: + '@testing-library/dom': 10.4.1 + '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) + '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.9(@types/node@26.1.0)(@vitest/browser-preview@4.1.9)(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0))) + vitest: 4.1.9(@types/node@26.1.0)(@vitest/browser-preview@4.1.9)(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0)) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + '@vitest/browser-preview@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.9)': dependencies: '@testing-library/dom': 10.4.1 @@ -13250,13 +13280,31 @@ snapshots: - msw - utf-8-validate - vite + optional: true - '@vitest/browser-preview@4.1.9(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0))(vitest@4.1.9(@types/node@26.1.0)(@vitest/browser-preview@4.1.9)(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0)))': + '@vitest/browser-preview@4.1.9(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0))(vitest@4.1.9)': dependencies: '@testing-library/dom': 10.4.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) - '@vitest/browser': 4.1.9(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0))(vitest@4.1.9(@types/node@26.1.0)(@vitest/browser-preview@4.1.9)(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0))) - vitest: 4.1.9(@types/node@26.1.0)(@vitest/browser-preview@4.1.9(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0))(vitest@4.1.9))(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0)) + '@vitest/browser': 4.1.9(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0))(vitest@4.1.9) + vitest: 4.1.9(@types/node@26.1.0)(@vitest/browser-preview@4.1.9)(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0)) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/browser@4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.9(@types/node@26.1.0)(@vitest/browser-preview@4.1.9)(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0)))': + dependencies: + '@blazediff/core': 1.9.1 + '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(typescript@6.0.3)(yaml@2.9.0)) + '@vitest/utils': 4.1.9 + magic-string: 0.30.21 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.1.0 + vitest: 4.1.9(@types/node@26.1.0)(@vitest/browser-preview@4.1.9)(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0)) + ws: 8.21.0 transitivePeerDependencies: - bufferutil - msw @@ -13279,8 +13327,9 @@ snapshots: - msw - utf-8-validate - vite + optional: true - '@vitest/browser@4.1.9(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0))(vitest@4.1.9(@types/node@26.1.0)(@vitest/browser-preview@4.1.9)(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0)))': + '@vitest/browser@4.1.9(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0))(vitest@4.1.9)': dependencies: '@blazediff/core': 1.9.1 '@vitest/mocker': 4.1.9(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0)) @@ -13289,7 +13338,7 @@ snapshots: pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.9(@types/node@26.1.0)(@vitest/browser-preview@4.1.9(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0))(vitest@4.1.9))(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0)) + vitest: 4.1.9(@types/node@26.1.0)(@vitest/browser-preview@4.1.9)(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0)) ws: 8.21.0 transitivePeerDependencies: - bufferutil @@ -17777,8 +17826,8 @@ snapshots: dependencies: '@oxc-project/types': 0.138.0 '@oxlint/plugins': 1.68.0 - '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.9) - '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.9) + '@vitest/browser': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.9(@types/node@26.1.0)(@vitest/browser-preview@4.1.9)(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0))) + '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.9(@types/node@26.1.0)(@vitest/browser-preview@4.1.9)(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0))) '@vitest/expect': 4.1.9 '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(typescript@6.0.3)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.9 @@ -17790,7 +17839,7 @@ snapshots: oxfmt: 0.57.0(svelte@5.56.4)(vite-plus@0.2.2(@types/node@26.1.0)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(svelte@5.56.4)(terser@5.48.0)(tsx@4.23.0)(typescript@6.0.3)(yaml@2.9.0)) oxlint: 1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@types/node@26.1.0)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(typescript@6.0.3)(yaml@2.9.0))(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(svelte@5.56.4)(terser@5.48.0)(tsx@4.23.0)(typescript@6.0.3)(yaml@2.9.0)) oxlint-tsgolint: 0.24.0 - vitest: 4.1.9(@types/node@26.1.0)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(typescript@6.0.3)(yaml@2.9.0)) + vitest: 4.1.9(@types/node@26.1.0)(@vitest/browser-preview@4.1.9(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0))(vitest@4.1.9))(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(typescript@6.0.3)(yaml@2.9.0)) optionalDependencies: '@voidzero-dev/vite-plus-darwin-arm64': 0.2.2 '@voidzero-dev/vite-plus-darwin-x64': 0.2.2 @@ -17835,8 +17884,8 @@ snapshots: dependencies: '@oxc-project/types': 0.138.0 '@oxlint/plugins': 1.68.0 - '@vitest/browser': 4.1.9(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0))(vitest@4.1.9(@types/node@26.1.0)(@vitest/browser-preview@4.1.9)(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0))) - '@vitest/browser-preview': 4.1.9(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0))(vitest@4.1.9(@types/node@26.1.0)(@vitest/browser-preview@4.1.9)(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0))) + '@vitest/browser': 4.1.9(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0))(vitest@4.1.9) + '@vitest/browser-preview': 4.1.9(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0))(vitest@4.1.9) '@vitest/expect': 4.1.9 '@vitest/mocker': 4.1.9(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.9 @@ -17848,7 +17897,7 @@ snapshots: oxfmt: 0.57.0(svelte@5.56.4)(vite-plus@0.2.2(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(svelte@5.56.4)(terser@5.48.0)(tsx@4.23.0)(typescript@6.0.3)(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0))(yaml@2.9.0)) oxlint: 1.72.0(oxlint-tsgolint@0.24.0)(vite-plus@0.2.2(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(svelte@5.56.4)(terser@5.48.0)(tsx@4.23.0)(typescript@6.0.3)(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0))(yaml@2.9.0)) oxlint-tsgolint: 0.24.0 - vitest: 4.1.9(@types/node@26.1.0)(@vitest/browser-preview@4.1.9(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0))(vitest@4.1.9))(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0)) + vitest: 4.1.9(@types/node@26.1.0)(@vitest/browser-preview@4.1.9)(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0)) optionalDependencies: '@voidzero-dev/vite-plus-darwin-arm64': 0.2.2 '@voidzero-dev/vite-plus-darwin-x64': 0.2.2 @@ -17972,10 +18021,10 @@ snapshots: - unrun - yaml - vitest@4.1.9(@types/node@26.1.0)(@vitest/browser-preview@4.1.9(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0))(vitest@4.1.9))(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0)): + vitest@4.1.9(@types/node@26.1.0)(@vitest/browser-preview@4.1.9(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0))(vitest@4.1.9))(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(typescript@6.0.3)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0)) + '@vitest/mocker': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(typescript@6.0.3)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.9 '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 @@ -17992,11 +18041,11 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0) + vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(typescript@6.0.3)(yaml@2.9.0)' why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.1.0 - '@vitest/browser-preview': 4.1.9(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0))(vitest@4.1.9(@types/node@26.1.0)(@vitest/browser-preview@4.1.9)(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0))) + '@vitest/browser-preview': 4.1.9(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0))(vitest@4.1.9) transitivePeerDependencies: - msw @@ -18027,6 +18076,35 @@ snapshots: '@vitest/browser-preview': 4.1.9(@voidzero-dev/vite-plus-core@0.2.2(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(typescript@6.0.3)(yaml@2.9.0))(vitest@4.1.9) transitivePeerDependencies: - msw + optional: true + + vitest@4.1.9(@types/node@26.1.0)(@vitest/browser-preview@4.1.9)(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.9 + '@vitest/mocker': 4.1.9(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.9 + '@vitest/runner': 4.1.9 + '@vitest/snapshot': 4.1.9 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + es-module-lexer: 2.3.0 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.1.0 + '@vitest/browser-preview': 4.1.9(vite@8.0.10(@types/node@26.1.0)(esbuild@0.27.7)(jiti@2.7.0)(sass@1.99.0)(terser@5.48.0)(tsx@4.23.0)(yaml@2.9.0))(vitest@4.1.9) + transitivePeerDependencies: + - msw vscode-uri@3.1.0: {} From 40c7e86248feac79770b48f9c5c12f3add157bf2 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 16 Jul 2026 20:05:21 +0800 Subject: [PATCH 026/128] fix(wrangler): close codex round-7 F4/F5 in the binding collector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F4 — run the canonical worker-wide binding-name collision collector + owner check for EVERY queue-producer upsert (matched/idempotent AND append), not only the `index === -1` append path. A pre-existing cross-type collision (or a malformed other binding field) now fails closed even on the idempotent path instead of being silently accepted as `modified:false`. The collector still omits `queues`, so a producer never self-collides. F5 — replace content-GUESSING with EXPLICIT per-field shape metadata transcribed from wrangler 4.107's config-schema.json (`BINDING_FIELD_SHAPES`): key-map fields (vars/wasm_modules/text_blobs/data_blobs) collect KEYS (never misread a value that looks like a binding list, never report a value as a key); required singletons (all but assets) throw on missing/malformed/wrong-shape binding; durable_objects and logfwdr require their `bindings` array (unsafe optional). `genericBindingNames` removed — one strict code path, no fallback guessing. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/cli/wrangler.ts | 334 +++++++++++++---------- packages/void/test/unit/wrangler.test.ts | 240 +++++++++++++++- 2 files changed, 423 insertions(+), 151 deletions(-) diff --git a/packages/void/src/cli/wrangler.ts b/packages/void/src/cli/wrangler.ts index 90e4256a0..5f8491f73 100644 --- a/packages/void/src/cli/wrangler.ts +++ b/packages/void/src/cli/wrangler.ts @@ -1148,29 +1148,31 @@ export function writeProvisionedWranglerConfig( } } index = matches.length === 1 ? matches[0] : -1; - if (index === -1) { - // APPEND branch only (no producer already owns this binding). wrangler - // requires every binding NAME to be unique across the ENTIRE worker — - // not just within `queues.producers`. So before appending a producer - // whose binding (`QUEUE_`) is ALREADY used by any OTHER binding - // (D1/KV/R2/DO/service/var/AI/hyperdrive/vectorize/…), fail closed: - // emitting it would produce a config wrangler rejects at deploy - // (cross-type duplicate binding name). A same-queue producer already - // owning that binding is a no-op handled above (index >= 0), never a - // collision. Build from the CURRENT text so an adopt appended earlier in - // this call is seen too (not the stale initial `parsed`). - workerBindingOwners ??= collectWorkerBindingNames(parseJsonc(text)); - const bindingName = String(entry.binding); - const owner = workerBindingOwners.get(bindingName); - if (owner !== undefined) { - throw new Error( - `deploy: cannot update "${configPath}" — queue producer binding ` + - `${JSON.stringify(bindingName)} is already used by a ${owner} binding. Wrangler ` + - `requires every binding name to be unique across the whole worker, so appending ` + - `this producer would make the config undeployable. Rename the conflicting binding ` + - `(or the queue) and retry.`, - ); - } + // F4 — run the canonical worker-wide binding-name collision check for EVERY + // producer upsert (matched/idempotent AND append), not only the `index === -1` + // append path. wrangler requires every binding NAME to be unique across the + // ENTIRE worker — not just within `queues.producers` — so a producer binding + // (`QUEUE_`) that ALSO names any OTHER binding (D1/KV/R2/DO/service/var/ + // secret/AI/hyperdrive/vectorize/…) is a cross-type collision wrangler rejects + // at deploy. That config is undeployable whether we APPEND the producer or leave + // a same-queue one already in place, so BOTH must fail closed: skipping the + // check on the idempotent path would silently accept an already-broken config + // with `modified:false`, and would also miss a malformed OTHER binding field the + // collector fails closed on. The collector deliberately OMITS `queues` (see + // FRIENDLY_BINDING_NAMES), so a producer never self-collides. Build from the + // CURRENT text so an adopt appended earlier in this call is seen too (not the + // stale initial `parsed`). + workerBindingOwners ??= collectWorkerBindingNames(parseJsonc(text)); + const bindingName = String(entry.binding); + const owner = workerBindingOwners.get(bindingName); + if (owner !== undefined) { + throw new Error( + `deploy: cannot update "${configPath}" — queue producer binding ` + + `${JSON.stringify(bindingName)} is already used by a ${owner} binding. Wrangler ` + + `requires every binding name to be unique across the whole worker, so this producer ` + + `would make the config undeployable. Rename the conflicting binding (or the queue) ` + + `and retry.`, + ); } } else { index = currentList.findIndex( @@ -1743,80 +1745,131 @@ function assertValidQueueProducers(producers: unknown): Array = { + // Key-maps — the KEYS are the binding names (env var / module / blob names). Never + // read the VALUES, so a value that looks like a binding list/object is not misread. + vars: { kind: 'key-map' }, + wasm_modules: { kind: 'key-map' }, + text_blobs: { kind: 'key-map' }, + data_blobs: { kind: 'key-map' }, + // Name-keyed arrays — identity on `.name` (wrangler's `getBindingNames` overlooks + // these; `validateSendEmailBinding`/`validateRateLimitBinding` require `.name`). + send_email: { kind: 'name-keyed-array' }, + ratelimits: { kind: 'name-keyed-array' }, + // Singletons `{ binding }` — `binding` REQUIRED per schema for all except `assets` + // (`validateAssetsConfig`, where a binding-less assets-only-directory is legal). + ai: { kind: 'singleton', bindingRequired: true }, + browser: { kind: 'singleton', bindingRequired: true }, + images: { kind: 'singleton', bindingRequired: true }, + stream: { kind: 'singleton', bindingRequired: true }, + media: { kind: 'singleton', bindingRequired: true }, + websearch: { kind: 'singleton', bindingRequired: true }, + version_metadata: { kind: 'singleton', bindingRequired: true }, + assets: { kind: 'singleton', bindingRequired: false }, + // Binding lists `{ bindings: [{ name }] }` — `bindings` REQUIRED for durable_objects + // and logfwdr; OPTIONAL for `unsafe` (which may carry only `metadata`). + durable_objects: { kind: 'binding-list', bindingsRequired: true }, + logfwdr: { kind: 'binding-list', bindingsRequired: true }, + unsafe: { kind: 'binding-list', bindingsRequired: false }, +}; /** - * F6 — config fields whose value is a SINGLE binding object `{ binding }` (wrangler - * parses these with a single-object validator, e.g. `validateAIBinding` / - * `validateNamedSimpleBinding` / `validateVersionMetadataBinding` / - * `validateAssetsConfig`). Known field-awarely so a PRESENT-but-non-string `binding` - * THROWS (fail closed) rather than yielding an empty set, WITHOUT misfiring on a - * KEY-MAP field (`vars`, blob/module maps) that could legitimately carry a key named - * `binding`. - * - * This is the COMPLETE singleton set per wrangler 4.107's config validator: `ai`, - * `browser`, `images`, `stream`, `media`, `websearch` (all `validateNamedSimpleBinding`), - * `version_metadata` (`validateVersionMetadataBinding`), and `assets` - * (`validateAssetsConfig`, whose `binding` is optional). Every OTHER - * `friendlyBindingNames` field is either an array (`validateBindingArray` — namespace - * list, covered by the generic strict path) or a binding LIST (`{ bindings }` — see - * BINDING_LIST_FIELDS), so none is missing here. + * F5 — the ONE field-aware strict binding-name reader for a single config field. Routes + * each field to its KNOWN shape (see {@link BINDING_FIELD_SHAPES}) and FAILS CLOSED on + * any malformed/missing REQUIRED identity or wrong outer shape — never content-guessing. + * wrangler is lenient and would silently drop such a member, letting the worker-wide + * uniqueness guard miss (or invent) a collision. */ -const SINGLETON_BINDING_FIELDS = new Set([ - 'ai', - 'browser', - 'images', - 'stream', - 'media', - 'websearch', - 'version_metadata', - 'assets', -]); +function bindingNamesForField(field: string, value: unknown): Array { + const shape: BindingFieldShape = BINDING_FIELD_SHAPES[field] ?? { kind: 'namespace-array' }; + switch (shape.kind) { + case 'key-map': + return keyMapBindingNames(field, value); + case 'name-keyed-array': + return nameKeyedBindingNames(field, value); + case 'namespace-array': + return namespaceArrayBindingNames(field, value); + case 'singleton': + return singletonBindingName(field, value, shape.bindingRequired); + case 'binding-list': + return bindingListNames(field, value, shape.bindingsRequired); + } +} /** - * F6 — the ONE field-aware strict binding-name reader for a single config field. - * Mirrors wrangler's `getBindingNames` but is field-aware and FAILS CLOSED on any - * malformed/empty binding identity (wrangler is lenient and would silently drop such - * a member, letting the worker-wide uniqueness guard miss a real collision). Routes - * each field to its known shape: NAME-keyed arrays, binding LISTS, SINGLETON objects; - * everything else (namespace arrays, key-maps) goes through `genericBindingNames`, - * which itself fails closed on a malformed namespace-array member. + * F5 — read binding names from a KEY-MAP field (`vars`/`wasm_modules`/`text_blobs`/ + * `data_blobs`): the object's KEYS are the binding names. Reads ONLY the keys — never + * the values — so a value that happens to look like a binding list (`{bindings:[…]}`) + * or a singleton (`{binding:'x'}`) is NOT misread (the old generic path returned the + * VALUE, or a nested `name`, instead of the real KEY). A PRESENT value that is not a + * plain object is the wrong outer shape and THROWS (fail closed). An explicitly + * `undefined`-valued key is not a real binding and is skipped. */ -function bindingNamesForField(field: string, value: unknown): Array { - if (NAME_KEYED_BINDING_ARRAYS.has(field)) { - return nameKeyedBindingNames(field, value); +function keyMapBindingNames(field: string, value: unknown): Array { + if (value === undefined) { + return []; + } + if (!isRecord(value)) { + throw new Error( + `deploy: cannot verify binding-name uniqueness — "${field}" must be an object mapping names ` + + `to values. Refusing to proceed on a partially-readable binding set (a wrong-shaped field ` + + `could hide a real collision). Fix the wrangler config and retry.`, + ); } - if (BINDING_LIST_FIELDS.has(field)) { - return bindingListNames(field, value); + return Object.keys(value).filter((key) => value[key] !== undefined); +} + +/** + * F5 — read binding names from a NAMESPACE-array field (`[{ binding }]`: kv_namespaces, + * d1_databases, r2_buckets, hyperdrive, services, vectorize, …). A PRESENT value that + * is not an array is the wrong outer shape and THROWS rather than being re-read as a + * key-map (which could under-collect and hide a real collision); every member is + * strictly validated for a non-empty string `.binding`. + */ +function namespaceArrayBindingNames(field: string, value: unknown): Array { + if (value === undefined) { + return []; } - if (SINGLETON_BINDING_FIELDS.has(field)) { - return singletonBindingName(field, value); + if (!Array.isArray(value)) { + throw new Error( + `deploy: cannot verify binding-name uniqueness — "${field}" must be an array of binding ` + + `entries. Refusing to proceed on a partially-readable binding set (a wrong-shaped field ` + + `could hide a real collision). Fix the wrangler config and retry.`, + ); } - return genericBindingNames(value); + return strictBindingArray(value, 'binding', field); } /** @@ -1848,19 +1901,20 @@ function nameKeyedBindingNames(field: string, value: unknown): Array { } /** - * F6 — read the binding names from a binding-LIST field (`{ bindings: [{ name }] }`). - * When the value carries a `bindings` array, EVERY member is strictly validated for a - * non-empty string `name` (a malformed member throws — never a partial/plain-map read - * that could hide a real binding). A value that is not the `{bindings: array}` shape - * defers to the generic reader (which is itself strict on namespace arrays). + * F5 — read the binding names from a binding-LIST field (`{ bindings: [{ name }] }`: + * durable_objects / logfwdr / unsafe). A PRESENT value that is not an object is the + * wrong outer shape and THROWS. When `bindingsRequired` (durable_objects / logfwdr per + * schema), a MISSING `bindings` array THROWS (never a silent empty read); `unsafe` is + * optional, so a `bindings`-less `{ metadata }` contributes nothing. A PRESENT + * `bindings` array has EVERY member strictly validated for a non-empty string `name`. */ -function bindingListNames(field: string, value: unknown): Array { +function bindingListNames(field: string, value: unknown, bindingsRequired: boolean): Array { if (value === undefined) { return []; } // Encode the REQUIRED outer shape: a binding-list field must be an object; a - // wrong-shaped value (array, string, …) THROWS rather than falling back to generic - // key collection (which could under-collect and hide a real binding collision). + // wrong-shaped value (array, string, …) THROWS rather than under-collecting (which + // could hide a real binding collision). if (!isRecord(value)) { throw new Error( `deploy: cannot verify binding-name uniqueness — "${field}" must be an object with a ` + @@ -1868,10 +1922,14 @@ function bindingListNames(field: string, value: unknown): Array { `field could hide a real collision). Fix the wrangler config and retry.`, ); } - // An object with NO `bindings` key contributes no binding names (e.g. `unsafe` - // carrying only `metadata`), matching wrangler — but a PRESENT `bindings` that is - // not an array is malformed and fails closed. if (value.bindings === undefined) { + if (bindingsRequired) { + throw new Error( + `deploy: cannot verify binding-name uniqueness — "${field}" requires a "bindings" array. ` + + `Refusing to proceed on a partially-readable binding set (a wrong-shaped field could hide ` + + `a real collision). Fix the wrangler config and retry.`, + ); + } return []; } if (!Array.isArray(value.bindings)) { @@ -1885,26 +1943,48 @@ function bindingListNames(field: string, value: unknown): Array { } /** - * F6 — read the binding name from a SINGLETON field (`{ binding }`). A PRESENT - * `binding` that is not a non-empty string is malformed and THROWS (fail closed): - * wrangler's `getBindingNames` returns `[value.binding]` here with NO type check, so a - * non-string would slip in as a bogus name and a malformed one would silently - * contribute nothing. An ABSENT `binding` (e.g. `assets` with only a `directory`) - * defers to the generic reader, matching wrangler. + * F5 — read the binding name from a SINGLETON field (`{ binding }`: ai / browser / + * images / stream / media / websearch / version_metadata / assets). A PRESENT value + * that is not an object is the wrong outer shape and THROWS. A PRESENT-but-malformed + * `binding` (non-string / empty) THROWS. When `bindingRequired` (every singleton except + * `assets`, per schema), an ABSENT `binding` also THROWS — never a silent empty read, + * and never a fall-through that would mis-collect the object's OTHER keys (e.g. + * `{ staging: true }`) as bogus binding names. `assets` is binding-optional, so an + * absent binding (e.g. `{ directory }`) contributes nothing. */ -function singletonBindingName(field: string, value: unknown): Array { - if ( - isRecord(value) && - value.binding !== undefined && - (typeof value.binding !== 'string' || value.binding.length === 0) - ) { +function singletonBindingName( + field: string, + value: unknown, + bindingRequired: boolean, +): Array { + if (value === undefined) { + return []; + } + if (!isRecord(value)) { + throw new Error( + `deploy: cannot verify binding-name uniqueness — "${field}" must be an object with a ` + + `"binding". Refusing to proceed on a partially-readable binding set (a wrong-shaped field ` + + `could hide a real collision). Fix the wrangler config and retry.`, + ); + } + if (typeof value.binding === 'string' && value.binding.length > 0) { + return [value.binding]; + } + if (value.binding !== undefined) { throw new Error( `deploy: cannot verify binding-name uniqueness — "${field}" has a malformed "binding" ` + `(expected a non-empty string). Refusing to proceed on a partially-readable binding set (a ` + `dropped binding could hide a real collision). Fix the wrangler config and retry.`, ); } - return genericBindingNames(value); + if (bindingRequired) { + throw new Error( + `deploy: cannot verify binding-name uniqueness — "${field}" requires a "binding" (a ` + + `non-empty string). Refusing to proceed on a partially-readable binding set (a dropped ` + + `binding could hide a real collision). Fix the wrangler config and retry.`, + ); + } + return []; } /** @@ -1938,46 +2018,6 @@ function strictBindingArray( }); } -/** - * Mirror wrangler's `getBindingNames`: extract the binding NAMES a config value - * contributes across the four shapes wrangler recognizes — a binding list - * (`{ bindings: [{ name }] }`, e.g. `durable_objects`/`unsafe`), a namespace list - * (`[{ binding }]`, e.g. `kv_namespaces`/`d1_databases`), a single record with a - * `binding` (e.g. `ai`/`browser`/`version_metadata`), and a plain record whose - * keys are the names (`vars`, blob/module maps). - */ -function genericBindingNames(value: unknown): Array { - if (typeof value !== 'object' || value === null) { - return []; - } - // Shape 1 — binding list `{ bindings: [{ name }] }` (durable_objects, unsafe). - // Strict: once this IS a binding list (at least one record member), any malformed - // member throws (F6) rather than falling through to a partial/garbage read. The - // `some(isRecord)` guard keeps a plain record that merely has a scalar key named - // `bindings` (e.g. a `vars` entry) on the shape-4 key path, matching prior - // behavior for that pathological case. - if (isRecord(value) && Array.isArray(value.bindings) && value.bindings.some(isRecord)) { - return strictBindingArray(value.bindings, 'name', 'bindings'); - } - // Shape 2 — namespace list `[{ binding }]` (kv_namespaces, d1_databases, - // r2_buckets, hyperdrive, …). Strict: a mixed-validity array (e.g. - // `[null, { binding: 'DB' }]`) throws instead of silently dropping the null and - // losing the valid `DB` binding (F6). NAME_KEYED arrays (send_email, ratelimits) - // never reach here — `bindingNamesForField` handles them before delegating. - if (Array.isArray(value)) { - return strictBindingArray(value, 'binding', 'namespace list'); - } - // Shape 3 + 4 — a single record with `binding` (ai/browser), or a plain record - // whose keys are the names (vars, blob/module maps). - if (isRecord(value)) { - if (value.binding !== undefined) { - return typeof value.binding === 'string' ? [value.binding] : []; - } - return Object.keys(value).filter((key) => value[key] !== undefined); - } - return []; -} - /** * True when a D1 `migrations_pattern` is valid under `dir` per wrangler's rule: * the normalized pattern must start with `${normalized dir}/` (or the dir diff --git a/packages/void/test/unit/wrangler.test.ts b/packages/void/test/unit/wrangler.test.ts index 07df94259..9adf97993 100644 --- a/packages/void/test/unit/wrangler.test.ts +++ b/packages/void/test/unit/wrangler.test.ts @@ -1295,10 +1295,10 @@ describe('writeProvisionedWranglerConfig', () => { }); it('worker-wide guard does not fire on the idempotent same-queue producer path (no false collision)', () => { - // QUEUE_JOBS already exists as a producer for the SAME queue. That is a no-op - // upsert (index >= 0), so the append-only worker-wide collision guard never - // runs — a producer already owning its binding for its own queue is not a - // collision. + // QUEUE_JOBS already exists as a producer for the SAME queue. The worker-wide + // collision guard now runs for EVERY producer upsert (F4), but the collector + // deliberately OMITS `queues`, so a producer never self-collides — with no OTHER + // binding owning QUEUE_JOBS the upsert stays an idempotent no-op. setup( 'wrangler.jsonc', `{ @@ -2530,3 +2530,235 @@ describe('writeProvisionedWranglerConfig', () => { expect(producers).toEqual([{ queue: 'jobs', binding: 'QUEUE_JOBS' }]); }); }); + +describe('writeProvisionedWranglerConfig — producer collision on EVERY upsert (round 7 F4)', () => { + const readRaw = (f: string): string => readFileSync(join(tmpDir, f), 'utf-8'); + const parseRaw = (f: string): Record => + parseJsoncRaw(readRaw(f)) as Record; + + it('fails closed on the IDEMPOTENT same-queue producer path when the binding cross-type collides — throws (not a silent modified:false)', () => { + // A D1 named QUEUE_JOBS PLUS an existing producer QUEUE_JOBS for the SAME queue: the + // config is already undeployable (cross-type binding-name collision wrangler + // rejects). The old code took the `index >= 0` path and returned `modified:false`, + // silently accepting it. The worker-wide collision check now runs for EVERY producer + // upsert (matched/idempotent AND append), so the idempotent path fails closed too. + setup( + 'wrangler.jsonc', + `{ + "d1_databases": [{ "binding": "QUEUE_JOBS", "database_name": "app", "database_id": "id" }], + "queues": { + "producers": [{ "queue": "jobs", "binding": "QUEUE_JOBS" }] + } +}`, + ); + const before = readRaw('wrangler.jsonc'); + expect(() => + writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'jobs', binding: 'QUEUE_JOBS' }] }, + }), + ).toThrow(/"QUEUE_JOBS" is already used by a D1 Database binding/); + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('fails closed on the idempotent path when a matched producer binding collides with a var name', () => { + setup( + 'wrangler.jsonc', + `{ + "vars": { "QUEUE_JOBS": "some-value" }, + "queues": { + "producers": [{ "queue": "jobs", "binding": "QUEUE_JOBS" }] + } +}`, + ); + const before = readRaw('wrangler.jsonc'); + expect(() => + writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'jobs', binding: 'QUEUE_JOBS' }] }, + }), + ).toThrow(/"QUEUE_JOBS" is already used by an? Environment Variable binding/); + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('fails closed on the idempotent path when a MALFORMED other binding field would mask a collision', () => { + // A null kv_namespaces member alongside the idempotent producer: the collector now + // runs on the idempotent path too, so a partially-readable binding set fails closed + // rather than silently accepting the config with modified:false. + setup( + 'wrangler.jsonc', + `{ + "kv_namespaces": [null, { "binding": "KV", "id": "abc" }], + "queues": { + "producers": [{ "queue": "jobs", "binding": "QUEUE_JOBS" }] + } +}`, + ); + const before = readRaw('wrangler.jsonc'); + expect(() => + writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'jobs', binding: 'QUEUE_JOBS' }] }, + }), + ).toThrow(/malformed entry at index 0 \(missing a non-empty string "binding"\)/); + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('negative control: idempotent same-queue producer with NO cross-type collision stays a no-op (modified:false)', () => { + setup( + 'wrangler.jsonc', + `{ + "queues": { + "producers": [{ "queue": "jobs", "binding": "QUEUE_JOBS" }] + } +}`, + ); + const before = readRaw('wrangler.jsonc'); + const result = writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'jobs', binding: 'QUEUE_JOBS' }] }, + }); + expect(result.modified).toBe(false); + expect(readRaw('wrangler.jsonc')).toBe(before); + // The producer is untouched. + expect((parseRaw('wrangler.jsonc').queues as Record).producers).toEqual([ + { queue: 'jobs', binding: 'QUEUE_JOBS' }, + ]); + }); +}); + +describe('worker-wide binding collector — schema-metadata field shapes (round 7 F5)', () => { + const readRaw = (f: string): string => readFileSync(join(tmpDir, f), 'utf-8'); + const parseRaw = (f: string): Record => + parseJsoncRaw(readRaw(f)) as Record; + + // Every case drives the collector through a queue-producer append of QUEUE_JOBS. + const appendProducer = () => + writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'jobs', binding: 'QUEUE_JOBS' }] }, + }); + + it('key-map: a `vars` value that LOOKS like a binding list is collected as KEYS (not misread) — no false collision', () => { + // A var literally named "bindings" holding an array-of-records VALUE. The old + // content-guessing read the nested `.name` ("QUEUE_JOBS") and falsely flagged a + // collision. Key-map handling collects the KEY ("bindings"), so the append proceeds. + setup('wrangler.jsonc', JSON.stringify({ vars: { bindings: [{ name: 'QUEUE_JOBS' }] } })); + const result = appendProducer(); + expect(result.modified).toBe(true); + expect((parseRaw('wrangler.jsonc').queues as Record).producers).toEqual([ + { queue: 'jobs', binding: 'QUEUE_JOBS' }, + ]); + }); + + it('key-map: a `vars` key named "binding" reports the KEY, not its VALUE — no false collision', () => { + // A var named "binding" whose VALUE is "QUEUE_JOBS". The old singleton misread + // returned the VALUE ("QUEUE_JOBS") → false collision. Key-map collects the KEY + // ("binding"), so the QUEUE_JOBS producer appends cleanly. + setup('wrangler.jsonc', JSON.stringify({ vars: { binding: 'QUEUE_JOBS' } })); + const result = appendProducer(); + expect(result.modified).toBe(true); + }); + + it('key-map: a real KEY collision (a var named QUEUE_JOBS) still fails closed', () => { + setup('wrangler.jsonc', JSON.stringify({ vars: { QUEUE_JOBS: { bindings: [] } } })); + const before = readRaw('wrangler.jsonc'); + expect(appendProducer).toThrow( + /"QUEUE_JOBS" is already used by an? Environment Variable binding/, + ); + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('key-map: a wrong-shaped `vars` (array, not object) fails closed', () => { + setup('wrangler.jsonc', JSON.stringify({ vars: [{ QUEUE_JOBS: 'x' }] })); + const before = readRaw('wrangler.jsonc'); + expect(appendProducer).toThrow(/"vars" must be an object mapping names to values/); + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('singleton: a required singleton (ai) with NO binding fails closed', () => { + setup('wrangler.jsonc', JSON.stringify({ ai: {} })); + const before = readRaw('wrangler.jsonc'); + expect(appendProducer).toThrow(/"ai" requires a "binding"/); + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('singleton: a required singleton (browser) present with only OTHER keys fails closed (not mis-collected)', () => { + // The old fall-through read `{ remote: true }` via generic key collection, inventing + // a bogus "remote" binding name. The required-binding rule fails closed instead. + setup('wrangler.jsonc', JSON.stringify({ browser: { remote: true } })); + const before = readRaw('wrangler.jsonc'); + expect(appendProducer).toThrow(/"browser" requires a "binding"/); + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('singleton: a wrong-shaped required singleton (ai as array) fails closed', () => { + setup('wrangler.jsonc', JSON.stringify({ ai: [{ binding: 'AI' }] })); + const before = readRaw('wrangler.jsonc'); + expect(appendProducer).toThrow(/"ai" must be an object with a "binding"/); + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('singleton: the OPTIONAL `assets` binding with only a directory contributes nothing (no throw, no bogus name)', () => { + // The old fall-through mis-collected "directory" as a binding name. assets is + // binding-optional, so it now contributes nothing and the producer appends. + setup('wrangler.jsonc', JSON.stringify({ assets: { directory: './public' } })); + const result = appendProducer(); + expect(result.modified).toBe(true); + }); + + it('binding-list: durable_objects missing the REQUIRED `bindings` array fails closed', () => { + setup('wrangler.jsonc', JSON.stringify({ durable_objects: {} })); + const before = readRaw('wrangler.jsonc'); + expect(appendProducer).toThrow(/"durable_objects" requires a "bindings" array/); + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('binding-list: logfwdr missing the REQUIRED `bindings` array fails closed', () => { + setup('wrangler.jsonc', JSON.stringify({ logfwdr: { schema: 'x' } })); + const before = readRaw('wrangler.jsonc'); + expect(appendProducer).toThrow(/"logfwdr" requires a "bindings" array/); + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('binding-list: a durable_objects member missing its `name` identity fails closed', () => { + setup( + 'wrangler.jsonc', + JSON.stringify({ durable_objects: { bindings: [{ class_name: 'C' }] } }), + ); + const before = readRaw('wrangler.jsonc'); + expect(appendProducer).toThrow( + /malformed entry at index 0 \(missing a non-empty string "name"\)/, + ); + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('binding-list: `unsafe.bindings` is OPTIONAL — metadata-only unsafe contributes nothing (no throw)', () => { + setup('wrangler.jsonc', JSON.stringify({ unsafe: { metadata: { foo: 'bar' } } })); + const result = appendProducer(); + expect(result.modified).toBe(true); + }); + + it('no false rejection: a VALID full config across every shape is collected correctly and the producer appends', () => { + // A rich, well-formed config exercising key-map / singleton / binding-list / + // namespace-array / name-keyed shapes — none of which names QUEUE_JOBS — must NOT + // be rejected. (A real cross-type collision would still fail closed; see above.) + setup( + 'wrangler.jsonc', + JSON.stringify({ + vars: { API_URL: 'https://x', bindings: [{ name: 'DECOY' }] }, + wasm_modules: { WASM_MOD: './m.wasm' }, + d1_databases: [{ binding: 'DB', database_name: 'app', database_id: 'id' }], + kv_namespaces: [{ binding: 'KV', id: 'abc' }], + ai: { binding: 'AI' }, + version_metadata: { binding: 'VERSION' }, + assets: { directory: './public' }, + durable_objects: { bindings: [{ name: 'DO', class_name: 'C' }] }, + unsafe: { metadata: { note: 'x' } }, + send_email: [{ name: 'EMAIL' }], + ratelimits: [{ name: 'LIMIT', namespace_id: '1001', simple: { limit: 100, period: 60 } }], + secrets: { required: ['SECRET_KEY'] }, + }), + ); + const result = appendProducer(); + expect(result.modified).toBe(true); + expect((parseRaw('wrangler.jsonc').queues as Record).producers).toEqual([ + { queue: 'jobs', binding: 'QUEUE_JOBS' }, + ]); + }); +}); From 78672c0325876a6cd53d20e61176605908c832dc Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 16 Jul 2026 21:14:59 +0800 Subject: [PATCH 027/128] fix(wrangler): close codex round-8 F3 outer-container validation in the binding collector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F3 — a PRESENT-but-wrong-shape `queues`/`secrets` container silently bypassed the worker-wide binding-name collector, hiding a real cross-type collision (an ARRAY-shaped `queues` carrying a producer binding `DB` let a D1 adopt as `DB` slip through). Add ONE shared `assertObjectContainer` validator that THROWS on a present non-object (array/string/number/null) and is a no-op only when the container is absent, and route the producer upsert, the adopt-guard owner collection, and the secrets fold-in all through it — no bypass. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/cli/wrangler.ts | 36 ++++++++-- packages/void/test/unit/wrangler.test.ts | 91 ++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 5 deletions(-) diff --git a/packages/void/src/cli/wrangler.ts b/packages/void/src/cli/wrangler.ts index 5f8491f73..dbefafa7d 100644 --- a/packages/void/src/cli/wrangler.ts +++ b/packages/void/src/cli/wrangler.ts @@ -1090,7 +1090,7 @@ export function writeProvisionedWranglerConfig( } // Re-parse `text` (not the initial `parsed`) so earlier edits in this call // are reflected; track appends locally to keep indices valid. - const currentQueues = parseJsonc(text).queues as Record | undefined; + const currentQueues = assertObjectContainer(parseJsonc(text).queues, 'queues'); // F5 — before mutating producers, route the EXISTING `queues.producers` list // through the SAME strict validator the adopt guard uses. Without this, a corrupt // list (e.g. `[null]`) would be cloned, its non-record members silently skipped by @@ -1643,8 +1643,8 @@ function collectWorkerBindingNames(config: Record): Map, config: Record, ): void { - const queues = config.queues; - if (!isRecord(queues) || queues.producers === undefined) { + const queues = assertObjectContainer(config.queues, 'queues'); + if (!queues || queues.producers === undefined) { return; } // F5/F6 — validate `queues.producers` STRICTLY (the ONE shared validator, also used @@ -1744,6 +1744,32 @@ function assertValidQueueProducers(producers: unknown): Array | undefined { + if (value === undefined) { + return undefined; + } + if (!isRecord(value)) { + throw new Error( + `deploy: cannot verify binding-name uniqueness — "${field}" must be an object. Refusing to ` + + `proceed on a partially-readable binding set (a wrong-shaped container could hide a real ` + + `collision). Fix the wrangler config and retry.`, + ); + } + return value; +} + /** * F5 — the OUTER binding-name SHAPE of a wrangler config field, so the collector reads * binding NAMES by KNOWN structure instead of GUESSING from a value's content (which diff --git a/packages/void/test/unit/wrangler.test.ts b/packages/void/test/unit/wrangler.test.ts index 9adf97993..6be3970c0 100644 --- a/packages/void/test/unit/wrangler.test.ts +++ b/packages/void/test/unit/wrangler.test.ts @@ -1316,6 +1316,97 @@ describe('writeProvisionedWranglerConfig', () => { expect(readRaw('wrangler.jsonc')).toBe(before); }); + it('F3: fails closed when an ARRAY-shaped `queues` (present) would hide a producer/D1 binding collision on adopt — throws without writing', () => { + // wrangler 4.107 REQUIRES `queues` to be an OBJECT `{ producers, consumers }`. + // A PRESENT array-shaped `queues` makes the old `isRecord(queues)` guard in + // `addQueueProducerOwners` return early, so a producer-like `{ binding: "DB" }` + // is NOT collected — and a D1 adopt of binding `DB` would then slip through the + // cross-type uniqueness check. The shared `assertObjectContainer` validator now + // fails closed on the malformed outer shape instead of hiding the collision. + setup( + 'wrangler.jsonc', + `{ + "queues": [{ "binding": "DB", "queue": "jobs" }], + "d1_databases": [] +}`, + ); + const before = readRaw('wrangler.jsonc'); + + expect(() => + writeProvisionedWranglerConfig(tmpDir, { + d1: { binding: 'DB', databaseName: 'app', databaseId: 'db-id' }, + }), + ).toThrow(/"queues" must be an object/); + + // Fail closed — the file is byte-unchanged (collision NOT hidden). + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('F3: fails closed when a WRONG-SHAPE `secrets` (present, non-object) would hide a secret binding collision on producer upsert — throws without writing', () => { + // wrangler requires `secrets` to be an OBJECT `{ required: string[] }`. A + // present-but-array `secrets` was silently skipped by the old `isRecord(secrets)` + // guard in `collectWorkerBindingNames`, so a producer could collide with a real + // secret binding name unnoticed. `assertObjectContainer` now fails closed. + setup( + 'wrangler.jsonc', + `{ + "secrets": ["QUEUE_JOBS"] +}`, + ); + const before = readRaw('wrangler.jsonc'); + + expect(() => + writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'jobs', binding: 'QUEUE_JOBS' }] }, + }), + ).toThrow(/"secrets" must be an object/); + + // Fail closed — the file is byte-unchanged. + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('F3: fails closed when an ARRAY-shaped `queues` (present) is seen on the producer UPSERT path — throws without writing (no bypass)', () => { + // The producer-upsert path (`upsertQueueEntries`) is ALSO routed through + // `assertObjectContainer`, so an array-shaped `queues` throws there too — proving + // no path can bypass the outer-shape validator. + setup( + 'wrangler.jsonc', + `{ + "queues": [{ "binding": "DB", "queue": "jobs" }] +}`, + ); + const before = readRaw('wrangler.jsonc'); + + expect(() => + writeProvisionedWranglerConfig(tmpDir, { + queues: { producers: [{ queue: 'jobs', binding: 'QUEUE_JOBS' }] }, + }), + ).toThrow(/"queues" must be an object/); + + // Fail closed — the file is byte-unchanged. + expect(readRaw('wrangler.jsonc')).toBe(before); + }); + + it('F3: ABSENT `queues` and `secrets` containers are a NO-OP — a D1 adopt with neither present succeeds normally', () => { + // Regression guard: `assertObjectContainer(undefined, …)` returns undefined (a + // no-op, NOT an error). A config with NO `queues` and NO `secrets` adopts a D1 + // binding normally. + setup( + 'wrangler.jsonc', + `{ + "d1_databases": [{ "binding": "DB", "database_name": "app", "database_id": "" }] +}`, + ); + + const result = writeProvisionedWranglerConfig(tmpDir, { + d1: { binding: 'DB', databaseName: 'app', databaseId: 'real-id' }, + }); + expect(result.modified).toBe(true); + + const d1 = parseRaw('wrangler.jsonc').d1_databases as Array>; + expect(d1[0]).toMatchObject({ binding: 'DB', database_id: 'real-id' }); + }); + it('fails closed when overwriting migrations_dir would invalidate a companion migrations_pattern — throws without writing', () => { // A valid drizzle pair (migrations_dir + migrations_pattern) becomes invalid // when the patch changes migrations_dir, because wrangler requires the pattern From cc25b4d364ea178b68efab895e1b509871eb4181 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 16 Jul 2026 21:15:14 +0800 Subject: [PATCH 028/128] fix(provision): close codex round-8 F1/F2/F4/F5 in the CF provisioner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F1 — the default runWrangler ran wrangler via UNBOUNDED spawnSync, which blocks the event loop; a call longer than the lock's stale window starved proper-lockfile's heartbeat, letting another process steal the still-held mutex. Switch to async `spawn` (awaited) so the loop stays free and the heartbeat always refreshes the mutex, plus a hard per-command timeout (SIGKILL → HARD failure) so a hung wrangler cannot hang the provision. The runWrangler seam is now async; sync test stubs still work via Promise.resolve at the single choke point. F2 — release swallowed unlock failures while proper-lockfile had already dropped its ownership state, leaking the mutex while the provision reported success. release() now caches one shared promise (idempotent double-release) and PROPAGATES a genuine (non-ERELEASED) unlock failure; on body success a leaked lock now FAILS the provision. F4 — replace the loose isRealValue on id-bearing checks with resource-SPECIFIC validators (D1 hyphenated UUID; KV + Hyperdrive 32-char hex; R2 bucket_name grammar), verified against wrangler 4.107 + live account resources. Applied to read-first reuse, list/info adoption, and the create postcondition, so `id: " "`/`"xyz"` is no longer mistaken for a real resource. F5 — split the universal option-injection guard (reject empty/leading-`-`/control chars, every positional) from per-resource service grammar, so a valid KV title with spaces ("My Own Namespace") is accepted while R2 keeps its lowercase DNS-like grammar. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/cli/provision.ts | 494 ++++++++++++++++++---- packages/void/test/unit/provision.test.ts | 371 ++++++++++++---- 2 files changed, 705 insertions(+), 160 deletions(-) diff --git a/packages/void/src/cli/provision.ts b/packages/void/src/cli/provision.ts index e8601b639..7ea8e120e 100644 --- a/packages/void/src/cli/provision.ts +++ b/packages/void/src/cli/provision.ts @@ -1,4 +1,4 @@ -import { spawnSync } from 'node:child_process'; +import { spawn } from 'node:child_process'; import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { parse, type ParseError } from 'jsonc-parser'; import { resolve } from 'pathe'; @@ -81,7 +81,19 @@ export interface WranglerCallContext { captureJson?: boolean; } -export type RunWrangler = (args: Array, opts: WranglerCallContext) => RunWranglerResult; +/** + * F1 — the seam returns a Promise (or a plain value, which the choke point coerces + * with `Promise.resolve`). The default runner is ASYNC (`spawn`, awaited) so a slow + * wrangler call NEVER blocks the event loop, which keeps `proper-lockfile`'s heartbeat + * timer firing (a synchronous `spawnSync` would freeze the loop, so the heartbeat could + * not refresh the mutex mtime and another process could steal a lock we still hold). + * Injected unit-test stubs may stay synchronous (returning a plain `RunWranglerResult`) + * — `Promise.resolve` at the choke point wraps them transparently. + */ +export type RunWrangler = ( + args: Array, + opts: WranglerCallContext, +) => RunWranglerResult | Promise; /** A Cloudflare Hyperdrive config as returned by the account-list existence check. */ export interface HyperdriveConfig { @@ -148,6 +160,42 @@ function isRealValue(value: unknown): value is string { return typeof value === 'string' && value.length > 0 && value !== 'local' && value !== 'default'; } +// --- F4: resource-SPECIFIC real-id format validators -------------------------- +// `isRealValue` alone (non-empty + not the `local`/`default` placeholders) let ANY +// nonempty string — `" "`, `"xyz"` — satisfy read-first reuse AND the create +// postcondition, so a resource could be reported provisioned with no valid identifier +// and NO discovery. Each id class has a FIXED Cloudflare format (verified 2026-07 +// against wrangler 4.107 + live account resources): +// - D1 `database_id`: a hyphenated UUID (e.g. `52b3bbc7-c473-4b26-9486-1345f5de61c4`). +// - KV namespace `id`: 32 lowercase-hex chars, no hyphens (e.g. `7c07b54c…153aa11a`). +// - Hyperdrive `id`: 32 lowercase-hex chars, no hyphens (e.g. `a8097c6a…34ad195c`) — +// NOT a hyphenated UUID (confirmed from a live config). +// - R2 identity IS the `bucket_name` (see {@link R2_BUCKET_NAME_RE}). +// These gate read-first reuse, discovery/adoption results, adoption writes, AND create +// postconditions — so a placeholder OR a malformed id fails closed (→ discovery, or a +// hard error) instead of being recorded as a real resource. +const D1_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const HEX32_ID_RE = /^[0-9a-f]{32}$/i; +/** R2 bucket names ARE the identity: wrangler 4.107's `isValidR2BucketName`. */ +const R2_BUCKET_NAME_RE = /^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/; + +/** A D1 `database_id` that is a real (non-placeholder) hyphenated UUID. */ +function isRealD1Id(value: unknown): value is string { + return isRealValue(value) && D1_ID_RE.test(value); +} +/** A KV namespace `id` that is a real (non-placeholder) 32-hex string. */ +function isRealKvId(value: unknown): value is string { + return isRealValue(value) && HEX32_ID_RE.test(value); +} +/** A Hyperdrive config `id` that is a real (non-placeholder) 32-hex string. */ +function isRealHyperdriveId(value: unknown): value is string { + return isRealValue(value) && HEX32_ID_RE.test(value); +} +/** An R2 `bucket_name` that is a real (non-placeholder) valid bucket name. */ +function isRealR2BucketName(value: unknown): value is string { + return isRealValue(value) && R2_BUCKET_NAME_RE.test(value); +} + const defaultLogger: ProvisionLogger = { info: (msg) => voidLog(msg), warn: (msg) => voidWarn(msg), @@ -184,30 +232,93 @@ export function provisionEnv(accountId?: string): NodeJS.ProcessEnv { return env; } +/** + * Hard per-command timeout for a single wrangler invocation. A hung wrangler must + * NEVER hang the whole provision, so the default runner SIGKILLs a child that runs + * past this and reports a HARD failure (a non-null signal — see + * {@link normalizeRunWranglerResult}). With the ASYNC runner this bound is + * INDEPENDENT of {@link LOCK_STALE_MS}: awaiting the child keeps the event loop free, + * so `proper-lockfile`'s heartbeat refreshes the mutex throughout — the timeout only + * bounds a hang, it is not load-bearing for lock safety. + */ +const COMMAND_TIMEOUT_MS = 120_000; + /** * Default `runWrangler`: resolves the project-local wrangler as an ARGV launcher - * via Task 2's {@link resolveWranglerArgv} and runs it with `spawnSync` and + * via Task 2's {@link resolveWranglerArgv} and runs it with `spawn` and * `shell: false`, so every argument is passed literally (no shell parsing → no * injection). Captures stdout (for `--json` parsing) and stderr (for * error/collision detection). Never called in unit tests — those inject a stub. + * + * F1 — ASYNC (returns a Promise). `spawn` + `await` (vs. the old blocking + * `spawnSync`) keeps the Node event loop free while wrangler runs, so + * `proper-lockfile`'s update timer keeps refreshing the mutex mtime for the WHOLE + * provision — no wrangler call, however slow, can freeze the loop long enough for + * another process to see the lock stale and steal it while we still hold it. A + * {@link COMMAND_TIMEOUT_MS} SIGKILL guards against a hung child. */ export function createDefaultRunWrangler(root: string): RunWrangler { const launcher = resolveWranglerArgv(root); const [command, ...prefix] = launcher; - return (args, opts) => { - const result = spawnSync(command, [...prefix, ...args], { - cwd: opts.cwd ?? root, - shell: false, - encoding: 'utf-8', - // F1 — pin the account PER CALL from the transport context, so the spawned - // wrangler targets the SAME reconciled account the config + Hyperdrive REST - // path use (no split-brain between the config pin and the ambient env). - env: provisionEnv(opts.accountId), - stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: true, + return (args, opts) => + new Promise((resolvePromise) => { + const child = spawn(command, [...prefix, ...args], { + cwd: opts.cwd ?? root, + shell: false, + // F1 — pin the account PER CALL from the transport context, so the spawned + // wrangler targets the SAME reconciled account the config + Hyperdrive REST + // path use (no split-brain between the config pin and the ambient env). + env: provisionEnv(opts.accountId), + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + let stdout = ''; + let stderr = ''; + let timedOut = false; + let settled = false; + child.stdout?.setEncoding('utf-8'); + child.stderr?.setEncoding('utf-8'); + child.stdout?.on('data', (chunk: string) => { + stdout += chunk; + }); + child.stderr?.on('data', (chunk: string) => { + stderr += chunk; + }); + // Hard timeout: SIGKILL the hung child. `close` then fires with a non-null + // signal, which `normalizeRunWranglerResult` classifies as a HARD failure. + const timer = setTimeout(() => { + timedOut = true; + child.kill('SIGKILL'); + }, COMMAND_TIMEOUT_MS); + timer.unref?.(); + const finish = (result: RunWranglerResult): void => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + resolvePromise(result); + }; + // A spawn error (ENOENT, EACCES) may fire `error` WITHOUT a later `close`, so + // settle here too — a HARD failure (never mistaken for a clean/absent exit). + child.on('error', (error: Error) => { + finish(normalizeRunWranglerResult({ stdout, stderr, status: null, signal: null, error })); + }); + child.on('close', (code, signal) => { + finish( + normalizeRunWranglerResult({ + stdout, + stderr: timedOut + ? [stderr, `provision: wrangler exceeded the ${COMMAND_TIMEOUT_MS}ms command timeout`] + .filter(Boolean) + .join('\n') + : stderr, + status: code, + signal, + }), + ); + }); }); - return normalizeRunWranglerResult(result); - }; } /** @@ -443,6 +554,16 @@ function selectHyperdriveMatch( `retry.`, ); } + // F4 — the sole exact-name match must carry a real 32-hex Hyperdrive id before we adopt + // it. A non-empty-but-malformed id (`" "`, `"xyz"`) fails closed rather than binding the + // app to a bogus id. + if (named[0] && !isRealHyperdriveId(named[0].id)) { + throw new Error( + `provision: cannot adopt Hyperdrive config ${JSON.stringify(name)} — its id ` + + `${JSON.stringify(named[0].id)} is not a valid 32-character hex Hyperdrive id. Refusing to ` + + `bind the app to an unverified config; inspect the account and retry.`, + ); + } return named[0]; } @@ -466,15 +587,42 @@ function selectHyperdriveMatch( // ownership-scoped release. interface ProvisionLock { + /** + * Drop the mutex. F2 — REJECTS on a genuine (non-`onCompromised`) unlock failure so a + * LEAKED lock is surfaced, never hidden behind a "successful" provision. Idempotent: + * repeated calls return the SAME settled promise. + */ release: () => Promise; } +/** + * Overridable `proper-lockfile` knobs — production uses the module defaults; unit tests + * pass a SHORT `stale`/`update` (to exercise heartbeat freshness under a slow call) or a + * failing `fs` (to exercise a leaked-mutex unlock failure). `fs` is `proper-lockfile`'s + * callback-style filesystem seam (mkdir/stat/utimes/rmdir/realpath). + */ +export interface ProvisionLockOptions { + stale?: number; + update?: number; + retries?: number | { retries: number; factor?: number; minTimeout?: number; maxTimeout?: number }; + fs?: unknown; +} + /** * A lock whose owner has not refreshed it within this window is treated as abandoned * (crashed) and may be compromised/stolen, so a crashed provision cannot wedge every * future one. A LIVE owner refreshes the mutex mtime every {@link LOCK_UPDATE_MS} * (strictly less than this), so it never expires by age. Generous vs. a real * provision's duration. + * + * F1 CORRECTNESS — this heartbeat is a `setTimeout` on the Node event loop, so it only + * fires while the loop is FREE. The default runner is ASYNC (`spawn`, awaited), so no + * wrangler call blocks the loop; between/`during` every `await` the pending update timer + * runs and refreshes the mtime. Fast synchronous bookkeeping (JSON parse, small config + * reads) is « `LOCK_UPDATE_MS`, so the mtime is continuously refreshed and NO other + * process ever sees this lock stale → it can never be stolen while we hold it. (The old + * blocking `spawnSync` could freeze the loop past `LOCK_STALE_MS`, starving the + * heartbeat AND `onCompromised`, which is exactly the race F1 closes.) */ const LOCK_STALE_MS = 120_000; /** Refresh interval keeping a live owner's lock fresh (must be < {@link LOCK_STALE_MS}). */ @@ -499,32 +647,58 @@ const LOCK_RETRIES = { retries: 100, factor: 1.5, minTimeout: 200, maxTimeout: 2 * mtime changed, or the mutex vanished under a live owner), provisioning must NEVER * continue — a compromised lock means another process may be racing the same config. */ -export async function acquireProvisionLock(configPath: string): Promise { +export async function acquireProvisionLock( + configPath: string, + lockOptions?: ProvisionLockOptions, +): Promise { const lockTarget = `${configPath}.void-provision.lock`; if (!existsSync(lockTarget)) { writeFileSync(lockTarget, ''); } + const staleMs = lockOptions?.stale ?? LOCK_STALE_MS; const release = await lockfile.lock(lockTarget, { - stale: LOCK_STALE_MS, - update: LOCK_UPDATE_MS, - retries: LOCK_RETRIES, + stale: staleMs, + update: lockOptions?.update ?? LOCK_UPDATE_MS, + retries: lockOptions?.retries ?? LOCK_RETRIES, + // `proper-lockfile`'s callback-style fs seam — overridable so a test can force an + // unlock failure. Production uses the library default (graceful-fs). + ...(lockOptions?.fs !== undefined ? { fs: lockOptions.fs as never } : {}), onCompromised: (error) => { throw error; }, }); - let released = false; - return { - release: async () => { - if (released) { + // F2 — cache ONE shared release promise so a double-release is idempotent (the SAME + // settled promise is returned, never a second `proper-lockfile` unlock), and PROPAGATE + // a genuine unlock failure instead of swallowing it. `proper-lockfile` drops its own + // ownership state (`released = true`, `delete locks[file]`) BEFORE it rmdir's the mutex + // dir, so an EACCES/EBUSY/I/O rmdir failure leaves the `.lock` directory on disk — a + // LEAKED cross-process mutex that blocks future provisions until it goes stale — while + // the old code resolved "released" and reported the provision a success. We now surface + // it: the caller fails the provision. (Because that same drop-before-rmdir ordering + // makes `proper-lockfile`'s own release non-retryable — a second call returns + // `ERELEASED` without re-attempting the rmdir — we do NOT retry via a manual dir sweep, + // which could race a successor; the leaked mutex self-heals for future runs after the + // stale window.) An `ERELEASED` error means the lock was ALREADY dropped (e.g. by an + // `onCompromised` sweep, whose re-throw is the authority) — not a leak we caused — so + // it is treated as success. + let releasePromise: Promise | undefined; + const doRelease = async (): Promise => { + try { + await release(); + } catch (error) { + if ((error as { code?: string } | null)?.code === 'ERELEASED') { return; } - released = true; - try { - await release(); - } catch { - // best-effort: a stale-compromise sweep may have already dropped the mutex - } - }, + throw new Error( + `provision: failed to release the cross-process provision lock "${lockTarget}.lock" ` + + `(${(error as Error).message}). The mutex directory may still exist and will block ` + + `future provisions until it goes stale (~${Math.round(staleMs / 1000)}s). Remove it ` + + `manually if it persists, then retry.`, + ); + } + }; + return { + release: () => (releasePromise ??= doRelease()), }; } @@ -750,6 +924,12 @@ function normalizeD1(entry: Record | undefined, requestedName: if (new Set(ids).size !== 1) { return { kind: 'claimed-but-invalid' }; } + // F4 — the lone id must be a real D1 UUID. A claimant that names the requested DB but + // carries a malformed/placeholder id (`" "`, `"xyz"`) is ambiguous, never adoptable — + // fail closed rather than adopt or (worse) mistake it for absent and mint a duplicate. + if (!isRealD1Id(ids[0])) { + return { kind: 'claimed-but-invalid' }; + } return { kind: 'match', id: ids[0] }; } @@ -757,6 +937,10 @@ function normalizeD1(entry: Record | undefined, requestedName: * `wrangler kv namespace list` → the `{ id }` of every namespace whose title * matches. The list has ALREADY been through {@link validateResourceList} (every * member has a non-empty string `id` + `title`), so this only filters + collects. + * + * F4 — a title-matching namespace whose `id` is NOT a real KV id (32-hex) is ambiguous: + * we can neither adopt it nor treat it as absent (which would mint a duplicate title), so + * we FAIL CLOSED. Real wrangler always returns 32-hex ids; this guards a malformed seam. */ function extractKvMatches( list: Array>, @@ -767,10 +951,14 @@ function extractKvMatches( if (ns.title !== title) { continue; } - const id = ns.id; - if (typeof id === 'string' && id.length > 0) { - matches.push({ id }); + if (!isRealKvId(ns.id)) { + throw new Error( + `provision: "wrangler kv namespace list" returned a namespace titled ${JSON.stringify(title)} ` + + `with a malformed id ${JSON.stringify(ns.id)} (expected a 32-character hex namespace id). ` + + `Cannot verify whether it already exists; refusing to create a possible duplicate.`, + ); } + matches.push({ id: ns.id }); } return matches; } @@ -863,6 +1051,13 @@ interface CreatePostcondition { field: string; /** The member key that must hold a REAL (non-placeholder) id afterward. */ idField: string; + /** + * F4 — resource-SPECIFIC id-format validator (D1 UUID / KV 32-hex / R2 bucket-name / + * Hyperdrive 32-hex). Replaces the loose `isRealValue`, so a create that reported + * success but wrote a placeholder/garbage id (e.g. a signal-killed child masked as + * exit 0) fails closed instead of being recorded as provisioned. + */ + idValidator: (value: unknown) => value is string; /** * F4 — when set, the member key that must EXACTLY equal the requested name (the * resource's IDENTITY). R2's `bucket_name` and D1's `database_name` ARE the @@ -901,7 +1096,7 @@ function assertCreateWroteRealId( name: string, ): void { const entry = findBindingEntry(readRootConfig(configPath)[spec.field], binding); - const idOk = !!entry && isRealValue(entry[spec.idField]); + const idOk = !!entry && spec.idValidator(entry[spec.idField]); const nameOk = spec.nameField === undefined || (!!entry && entry[spec.nameField] === name); if (!idOk || !nameOk) { throw new Error( @@ -915,26 +1110,109 @@ function assertCreateWroteRealId( } } +// --- F5: option-injection guard SEPARATE from per-resource service grammar ----- +// The old single regex `^[A-Za-z0-9][A-Za-z0-9_-]{0,62}$` was applied to EVERY +// resource, but wrangler 4.107 forwards the KV namespace positional VERBATIM as the KV +// TITLE, and Cloudflare permits titles like "My Own Namespace" (spaces) — so valid KV +// namespaces were REJECTED before discovery. It was also wrong for R2 (whose real +// grammar is lowercase DNS-like). We therefore split the two concerns: +// 1. OPTION-INJECTION guard — a SECURITY invariant applied to EVERY positional, +// identical across resources: shell-free spawning stops shell injection but NOT CLI +// OPTION injection, so a positional starting with `-` (`--help`, `-c`, `--config`) +// would be parsed by wrangler as a FLAG (a queue name is derived from a filename → +// attacker-influenced). We reject empty, a leading `-`, and any control char / +// newline (which could smuggle a token or corrupt argv). +// 2. SERVICE grammar — each resource's ACTUAL Cloudflare grammar (verified +// 2026-07 against wrangler 4.107 + live resources), so a valid name is never +// rejected AND obvious garbage still fails before a remote call. + +/** True when `value` contains an ASCII control character (0x00–0x1F or 0x7F). */ +function hasControlChar(value: string): boolean { + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + if (code < 0x20 || code === 0x7f) { + return true; + } + } + return false; +} + +/** F5.1 — the universal option-injection guard for ANY wrangler ARGV positional. */ +function assertNoOptionInjection(kind: string, name: string): void { + if ( + typeof name !== 'string' || + name.length === 0 || + name.startsWith('-') || + hasControlChar(name) + ) { + throw new Error( + `provision: refusing to pass an unsafe ${kind} name ${JSON.stringify(name)} to wrangler. A ` + + `name may not be empty, begin with "-" (it would be parsed as a CLI flag), or contain ` + + `control characters. Rename the resource and retry.`, + ); + } +} + +type ResourceNameKind = 'd1' | 'kvTitle' | 'r2' | 'hyperdrive' | 'queue'; + /** - * F2 — Cloudflare resource-name grammar enforced on EVERY name placed as a - * wrangler ARGV POSITIONAL (D1 db name, KV title, R2 bucket, Hyperdrive name, - * queue name) BEFORE it reaches argv. Shell-free spawning stops shell injection - * but NOT CLI OPTION injection: a name like `--help`, `--config`, or `-h` would be - * parsed by wrangler as a FLAG (and a queue name is derived from a filename → it - * is attacker-influenced). We require the name to START with an ASCII letter or - * digit and contain only ASCII letters, digits, `-` and `_`, 1–63 chars. That - * rejects any leading `-` (the flag vector) plus whitespace/metacharacters, and is - * a safe superset of Cloudflare's per-resource naming rules. Fail closed on any - * violation — an option-like name never reaches wrangler. + * F5.2 — per-resource Cloudflare naming GRAMMAR, verified 2026-07 against wrangler 4.107 + * and live account resources: + * - `r2` — wrangler's own `isValidR2BucketName`: 3–63 chars, lowercase letters / + * digits / `-`, begin+end alphanumeric (DNS-like). This IS the identity. + * - `kvTitle` — the KV namespace positional is forwarded verbatim as the account TITLE; + * Cloudflare allows a broad charset INCLUDING SPACES (≤512 chars). We only + * forbid control chars (already covered by the injection guard) — spaces + * and mixed case are legal, so "My Own Namespace" is accepted. + * - `d1` / `hyperdrive` / `queue` — 1–63 chars, begin with a letter/digit, then + * letters / digits / `-` / `_` (matches every live D1 `name`, Hyperdrive + * config `name`, and Void's `QUEUE_`-derived queue names). */ -const RESOURCE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,62}$/; -function assertValidResourceName(kind: string, name: string): void { - if (typeof name !== 'string' || !RESOURCE_NAME_RE.test(name)) { +const RESOURCE_NAME_GRAMMARS: Record< + ResourceNameKind, + { re: RegExp; label: string; rule: string } +> = { + r2: { + re: R2_BUCKET_NAME_RE, + label: 'R2 bucket', + rule: 'be 3–63 characters, contain only lowercase letters, digits and "-", and begin and end with a letter or digit', + }, + kvTitle: { + // 1–512 chars, any non-control character (spaces allowed). The leading-"-" / control + // guard is enforced separately by assertNoOptionInjection. + re: /^[\s\S]{1,512}$/, + label: 'KV namespace', + rule: 'be 1–512 characters and contain no control characters (spaces are allowed)', + }, + d1: { + re: /^[A-Za-z0-9][A-Za-z0-9_-]{0,62}$/, + label: 'D1 database', + rule: 'be 1–63 characters, start with a letter or digit, and contain only letters, digits, "-" or "_"', + }, + hyperdrive: { + re: /^[A-Za-z0-9][A-Za-z0-9_-]{0,62}$/, + label: 'Hyperdrive config', + rule: 'be 1–63 characters, start with a letter or digit, and contain only letters, digits, "-" or "_"', + }, + queue: { + re: /^[A-Za-z0-9][A-Za-z0-9_-]{0,62}$/, + label: 'queue', + rule: 'be 1–63 characters, start with a letter or digit, and contain only letters, digits, "-" or "_"', + }, +}; + +/** + * F5 — validate a name destined for a wrangler ARGV positional: FIRST the universal + * option-injection guard, THEN the resource's own Cloudflare grammar. Fail closed on any + * violation, before the account gate or any wrangler/REST call. + */ +function assertValidResourceName(kind: ResourceNameKind, name: string): void { + assertNoOptionInjection(RESOURCE_NAME_GRAMMARS[kind].label, name); + const grammar = RESOURCE_NAME_GRAMMARS[kind]; + if (!grammar.re.test(name)) { throw new Error( - `provision: refusing to pass an invalid ${kind} name ${JSON.stringify(name)} to wrangler. ` + - `Resource names must be 1–63 characters, start with a letter or digit, and contain only ` + - `letters, digits, "-" or "_" (a name beginning with "-" would be parsed as a CLI flag). ` + - `Rename the resource and retry.`, + `provision: refusing to pass an invalid ${grammar.label} name ${JSON.stringify(name)} to ` + + `wrangler. A ${grammar.label} name must ${grammar.rule}. Rename the resource and retry.`, ); } } @@ -988,16 +1266,36 @@ export async function provisionCloudflareResources( // F1/F2 — hold ONE cross-process lock for the WHOLE provision: acquire it BEFORE // the first config read (so account reconciliation + persistence + all discovery, // remote work, and config writes happen against the state the lock protects), and - // ALWAYS release it in `finally` (an interruption must never leak the lock). The - // reconciliation + duplicate-member guard + resource work all run UNDER the lock in + // ALWAYS release it (both the success and error paths below call `lock.release()`, so + // an interruption never leaks the lock; a genuine unlock failure fails the provision). + // The reconciliation + duplicate-member guard + resource work all run UNDER the lock in // `provisionCloudflareResourcesLocked`, so a racing first-run serializes: the second // process re-reads the persisted account/ids and fails closed (or reuses). - const lock = await acquireProvisionLock(configPath); + const lock = await acquireProvisionLock(configPath, opts?.lockOptions); + // F2 — ALWAYS release, but do NOT swallow a release failure. On body SUCCESS a failed + // unlock means the mutex leaked, so the provision must FAIL (surfaced, not hidden). On + // body FAILURE the body error is primary, so still attempt release but never let a + // release failure mask the original error. (`release()` is idempotent — one shared + // promise — so these paths never double-unlock.) + let result: ProvisionResult | undefined; + let threw = false; + let bodyError: unknown; try { - return await provisionCloudflareResourcesLocked(root, configPath, desired, opts); - } finally { - await lock.release(); + result = await provisionCloudflareResourcesLocked(root, configPath, desired, opts); + } catch (error) { + threw = true; + bodyError = error; + } + if (threw) { + await lock.release().catch(() => { + // The body already failed — the body error is the primary failure to surface; a + // secondary release failure here must not replace it. + }); + throw bodyError; } + // Body succeeded: a genuine unlock failure (leaked mutex) rejects the provision. + await lock.release(); + return result as ProvisionResult; } /** Injectable seams + settings for {@link provisionCloudflareResources}. */ @@ -1007,6 +1305,8 @@ export interface ProvisionOptions { apiToken?: string; listHyperdriveConfigs?: ListHyperdriveConfigs; log?: ProvisionLogger; + /** Test-only override of the `proper-lockfile` stale/update/retries/fs knobs (F1/F2). */ + lockOptions?: ProvisionLockOptions; } /** @@ -1035,22 +1335,23 @@ async function provisionCloudflareResourcesLocked( assertNoDuplicateMembers(content, configPath); } - // --- F2: resource-name validation (BEFORE any argv / remote work) --------- - // Every name that becomes a wrangler ARGV POSITIONAL is validated up front, so an - // option-like name (`--help`, `-h`, `--config`) can never reach wrangler and be - // parsed as a flag. Fail closed here, before the account gate or any wrangler/REST - // call. + // --- F5: resource-name validation (BEFORE any argv / remote work) --------- + // Every name that becomes a wrangler ARGV POSITIONAL runs through the universal + // option-injection guard (so an option-like name `--help`/`-h`/`--config` can never + // reach wrangler as a flag) AND its own per-resource Cloudflare grammar (so a valid KV + // title with spaces is accepted, while an invalid R2 bucket name is rejected). Fail + // closed here, before the account gate or any wrangler/REST call. if (desired.d1) { - assertValidResourceName('D1 database', desired.d1.name); + assertValidResourceName('d1', desired.d1.name); } for (const { title } of desired.kvNamespaces) { - assertValidResourceName('KV namespace', title); + assertValidResourceName('kvTitle', title); } if (desired.r2) { - assertValidResourceName('R2 bucket', desired.r2.bucketName); + assertValidResourceName('r2', desired.r2.bucketName); } if (desired.hyperdrive) { - assertValidResourceName('Hyperdrive config', desired.hyperdrive.name); + assertValidResourceName('hyperdrive', desired.hyperdrive.name); } for (const queueName of desired.queues) { assertValidResourceName('queue', queueName); @@ -1148,16 +1449,20 @@ async function provisionCloudflareResourcesLocked( const selectedRunWrangler = opts?.runWrangler ?? createDefaultRunWrangler(root); // Single choke point: inject the reconciled account into EVERY call so no call site // can forget it (grep-proven — every `runWrangler(...)` below routes through here). + // F1 — async: `Promise.resolve` coerces a sync-stub result OR the default runner's + // Promise, and every call site `await`s it. Awaiting yields to the event loop, so a + // real (slow) wrangler call never blocks the lock heartbeat. const runWrangler = ( args: Array, callOpts?: { cwd?: string; captureJson?: boolean }, - ): RunWranglerResult => selectedRunWrangler(args, { ...callOpts, accountId: transportAccountId }); + ): Promise => + Promise.resolve(selectedRunWrangler(args, { ...callOpts, accountId: transportAccountId })); // --- D1 ------------------------------------------------------------------ if (desired.d1) { const { binding, name } = desired.d1; const entry = findBindingEntry(readRootConfig(configPath).d1_databases, binding); - if (entry && isRealValue(entry.database_id)) { + if (entry && isRealD1Id(entry.database_id)) { // Read-first hit. A prior run may have created the D1 (id written) then // crashed BEFORE migrations_dir was set, so ensure + verify it here too — // idempotent when already present — so the retry converges rather than @@ -1169,9 +1474,12 @@ async function provisionCloudflareResourcesLocked( // Discovery must SUCCEED (gate) before its stdout is parsed, then the whole // list is strictly validated — a nonzero/SIGTERM/spawn-error or a malformed // member never parses as "absent" and mints a duplicate D1. - const listResult = runWrangler(['d1', 'list', '--json', ...wranglerConfigArgs(configPath)], { - captureJson: true, - }); + const listResult = await runWrangler( + ['d1', 'list', '--json', ...wranglerConfigArgs(configPath)], + { + captureJson: true, + }, + ); assertDiscoverySucceeded(listResult, 'wrangler d1 list'); const rawList = parseJsonArrayStrict(listResult.stdout); if (!rawList) { @@ -1224,7 +1532,7 @@ async function provisionCloudflareResourcesLocked( assertD1MigrationsDirApplied(res, binding, configPath); resources.push({ kind: 'd1', binding, name, action: 'adopted', id: existing.id }); } else { - const created = runWrangler([ + const created = await runWrangler([ 'd1', 'create', name, @@ -1242,6 +1550,7 @@ async function provisionCloudflareResourcesLocked( { field: 'd1_databases', idField: 'database_id', + idValidator: isRealD1Id, nameField: 'database_name', resource: 'D1 database', command: 'wrangler d1 create', @@ -1256,7 +1565,7 @@ async function provisionCloudflareResourcesLocked( // TOCTOU: created between our list and create (clean non-zero, code 7502). // Capture the id via `d1 info` — which must SUCCEED (gate) and POSITIVELY // identify THIS database (EXACT name match) before we adopt its id. - const infoResult = runWrangler( + const infoResult = await runWrangler( ['d1', 'info', name, '--json', ...wranglerConfigArgs(configPath)], { captureJson: true }, ); @@ -1292,7 +1601,7 @@ async function provisionCloudflareResourcesLocked( // --- KV ------------------------------------------------------------------ for (const { binding, title } of desired.kvNamespaces) { const entry = findBindingEntry(readRootConfig(configPath).kv_namespaces, binding); - if (entry && isRealValue(entry.id)) { + if (entry && isRealKvId(entry.id)) { resources.push({ kind: 'kv', binding, name: title, action: 'reused', id: entry.id }); continue; } @@ -1302,9 +1611,12 @@ async function provisionCloudflareResourcesLocked( // throw), return a real JSON array, and have EVERY member well-formed (a // malformed member — which could BE the target — fails closed rather than being // filtered to a false "absent" that mints a duplicate namespace). - const listResult = runWrangler(['kv', 'namespace', 'list', ...wranglerConfigArgs(configPath)], { - captureJson: true, - }); + const listResult = await runWrangler( + ['kv', 'namespace', 'list', ...wranglerConfigArgs(configPath)], + { + captureJson: true, + }, + ); assertDiscoverySucceeded(listResult, 'wrangler kv namespace list'); const rawList = parseJsonArrayStrict(listResult.stdout); if (!rawList) { @@ -1336,7 +1648,7 @@ async function provisionCloudflareResourcesLocked( write({ kv: { binding, id: chosen.id } }); resources.push({ kind: 'kv', binding, name: title, action: 'adopted', id: chosen.id }); } else { - const created = runWrangler([ + const created = await runWrangler([ 'kv', 'namespace', 'create', @@ -1358,6 +1670,7 @@ async function provisionCloudflareResourcesLocked( { field: 'kv_namespaces', idField: 'id', + idValidator: isRealKvId, resource: 'KV namespace', command: 'wrangler kv namespace create', }, @@ -1372,12 +1685,12 @@ async function provisionCloudflareResourcesLocked( if (desired.r2) { const { binding, bucketName } = desired.r2; const entry = findBindingEntry(readRootConfig(configPath).r2_buckets, binding); - if (entry && isRealValue(entry.bucket_name)) { + if (entry && isRealR2BucketName(entry.bucket_name)) { resources.push({ kind: 'r2', binding, name: bucketName, action: 'reused' }); } else { // `wrangler r2 bucket list` is a human table, not JSON. Bucket names ARE // account-unique, so probe the single bucket by name. - const info = runWrangler( + const info = await runWrangler( ['r2', 'bucket', 'info', bucketName, '--json', ...wranglerConfigArgs(configPath)], { captureJson: true }, ); @@ -1401,7 +1714,7 @@ async function provisionCloudflareResourcesLocked( } else if (isConfirmedAbsent(info, 'r2', bucketName)) { // CONFIRMED not-found (clean non-zero + exact signal) → create. // `--update-config` upserts by binding. - const created = runWrangler([ + const created = await runWrangler([ 'r2', 'bucket', 'create', @@ -1422,6 +1735,7 @@ async function provisionCloudflareResourcesLocked( { field: 'r2_buckets', idField: 'bucket_name', + idValidator: isRealR2BucketName, nameField: 'bucket_name', resource: 'R2 bucket', command: 'wrangler r2 bucket create', @@ -1457,7 +1771,7 @@ async function provisionCloudflareResourcesLocked( // across processes: the read-first check below runs UNDER that lock, so a second // concurrent provision sees the id the first wrote and SKIPS (no duplicate). const entry = findBindingEntry(readRootConfig(configPath).hyperdrive, binding); - if (entry && isRealValue(entry.id)) { + if (entry && isRealHyperdriveId(entry.id)) { resources.push({ kind: 'hyperdrive', binding, name, action: 'reused', id: entry.id }); } else { const lister = @@ -1518,7 +1832,7 @@ async function provisionCloudflareResourcesLocked( ); } else { // Confirmed absent + connection string present → create. - const created = runWrangler([ + const created = await runWrangler([ 'hyperdrive', 'create', name, @@ -1539,6 +1853,7 @@ async function provisionCloudflareResourcesLocked( { field: 'hyperdrive', idField: 'id', + idValidator: isRealHyperdriveId, resource: 'Hyperdrive config', command: 'wrangler hyperdrive create', }, @@ -1557,7 +1872,7 @@ async function provisionCloudflareResourcesLocked( // queue RESOURCE exists — binding the producers/consumers into the config is // app-class-aware and belongs to Task 6. for (const name of desired.queues) { - const info = runWrangler(['queues', 'info', name, ...wranglerConfigArgs(configPath)]); + const info = await runWrangler(['queues', 'info', name, ...wranglerConfigArgs(configPath)]); // "Exists → reuse" ONLY when the probe POSITIVELY names THIS queue (F4). A bare // clean 0 is NOT trusted: the bin wrapper can mask a signal-killed inner CLI as // exit 0 with empty output, which a plain `isCleanSuccess` would misread as @@ -1577,7 +1892,12 @@ async function provisionCloudflareResourcesLocked( `${info.stderr || info.stdout || 'no output'}`, ); } - const created = runWrangler(['queues', 'create', name, ...wranglerConfigArgs(configPath)]); + const created = await runWrangler([ + 'queues', + 'create', + name, + ...wranglerConfigArgs(configPath), + ]); if (!isCleanSuccess(created)) { throw new Error( `provision: "wrangler queues create ${name}" failed: ${created.stderr || created.stdout}`, @@ -1588,7 +1908,7 @@ async function provisionCloudflareResourcesLocked( // signal-killed inner CLI as exit 0, creating nothing). RE-RUN the SAME positive // `queues info` probe and only record `created` if it now confirms THIS queue; // otherwise fail closed (a killed create self-heals on the next run's probe). - const verify = runWrangler(['queues', 'info', name, ...wranglerConfigArgs(configPath)]); + const verify = await runWrangler(['queues', 'info', name, ...wranglerConfigArgs(configPath)]); if (!queueInfoConfirmsExists(verify, name)) { throw new Error( `provision: "wrangler queues create ${name}" reported success but "wrangler queues info ` + diff --git a/packages/void/test/unit/provision.test.ts b/packages/void/test/unit/provision.test.ts index b88fc84ee..67a7edc0b 100644 --- a/packages/void/test/unit/provision.test.ts +++ b/packages/void/test/unit/provision.test.ts @@ -1,4 +1,17 @@ -import { existsSync, mkdirSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; +import { + existsSync, + mkdir, + mkdirSync, + readFileSync, + realpath, + rmdir, + rmdirSync, + rmSync, + stat, + utimes, + utimesSync, + writeFileSync, +} from 'node:fs'; import { parse as parseJsoncRaw } from 'jsonc-parser'; import { join } from 'pathe'; import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'; @@ -12,6 +25,12 @@ import { type RunWranglerResult, } from '../../src/cli/provision.ts'; +/** + * A passthrough of proper-lockfile's callback-style fs seam (mkdir/stat/utimes/rmdir/ + * realpath + a sync rmdir). The F2 unlock-failure test overrides `rmdir` on top of this. + */ +const passthruFs = { mkdir, stat, utimes, rmdir, realpath, rmdirSync }; + const tmpDir = join(import.meta.dirname, '.tmp-provision-test'); const wranglerPath = join(tmpDir, 'wrangler.jsonc'); @@ -84,6 +103,19 @@ function isCall(argv: Array, ...prefix: Array): boolean { const ok = (stdout = ''): RunWranglerResult => ({ stdout, stderr: '', status: 0 }); const fail = (stdout: string, stderr = ''): RunWranglerResult => ({ stdout, stderr, status: 1 }); +// Valid-FORMAT synthetic ids for fixtures. F4's validators require REAL shapes: +// D1 database_id is a hyphenated UUID; KV + Hyperdrive ids are 32-char lowercase hex. +// ASCII-hex of the seed, zero-padded to 32 — deterministic, distinct, and ORDER-PRESERVING +// on equal-length seeds (so lexicographic tie-break assertions stay valid). +function hex32(seed: string): string { + const raw = Buffer.from(seed, 'utf8').toString('hex').slice(0, 32); + return (raw + '0'.repeat(32)).slice(0, 32); +} +function uuid(seed: string): string { + const h = hex32(seed); + return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`; +} + /** Mimic `wrangler create --update-config`: upsert a binding entry by binding. */ function simulateUpdateConfig( field: string, @@ -108,7 +140,7 @@ describe('provisionCloudflareResources — read-first', () => { it('skips D1 create when the binding already has a real database_id (records reused, no wrangler call)', async () => { setup( JSON.stringify({ - d1_databases: [{ binding: 'DB', database_name: 'myapp-db', database_id: 'real-uuid-1' }], + d1_databases: [{ binding: 'DB', database_name: 'myapp-db', database_id: uuid('db1') }], }), ); const { run, calls } = makeRunner([]); @@ -121,7 +153,7 @@ describe('provisionCloudflareResources — read-first', () => { // Read-first: no wrangler invocation at all (migrations_dir is filled via the writer). expect(calls).toEqual([]); expect(result.resources).toEqual([ - { kind: 'd1', binding: 'DB', name: 'myapp-db', action: 'reused', id: 'real-uuid-1' }, + { kind: 'd1', binding: 'DB', name: 'myapp-db', action: 'reused', id: uuid('db1') }, ]); }); @@ -139,7 +171,7 @@ describe('provisionCloudflareResources — read-first', () => { simulateUpdateConfig('d1_databases', { binding: 'DB', database_name: 'myapp-db', - database_id: 'created-uuid', + database_id: uuid('created'), }); return ok(); }, @@ -165,7 +197,7 @@ describe('provisionCloudflareResources — argv injection safety', () => { { match: /^hyperdrive create /, reply: () => { - simulateUpdateConfig('hyperdrive', { binding: 'HYPERDRIVE', id: 'new-hd-id' }); + simulateUpdateConfig('hyperdrive', { binding: 'HYPERDRIVE', id: hex32('newhd') }); return ok(); }, }, @@ -211,7 +243,7 @@ describe('provisionCloudflareResources — argv injection safety', () => { reply: () => { simulateUpdateConfig( 'd1_databases', - { binding: 'DB', database_name: 'myapp-db', database_id: 'created-uuid' }, + { binding: 'DB', database_name: 'myapp-db', database_id: uuid('created') }, spacedPath, ); return ok(); @@ -250,7 +282,7 @@ describe('provisionCloudflareResources — config discovery (fail closed, single setup( '' + JSON.stringify({ - d1_databases: [{ binding: 'DB', database_name: 'myapp-db', database_id: 'real-uuid-1' }], + d1_databases: [{ binding: 'DB', database_name: 'myapp-db', database_id: uuid('db1') }], }), ); const { run, calls } = makeRunner([]); @@ -262,7 +294,7 @@ describe('provisionCloudflareResources — config discovery (fail closed, single ); // A BOM must NOT defeat read-first (a fail-open-to-{} parse would re-provision). expect(calls.some((c) => c[0] === 'd1')).toBe(false); - expect(result.resources[0]).toMatchObject({ kind: 'd1', action: 'reused', id: 'real-uuid-1' }); + expect(result.resources[0]).toMatchObject({ kind: 'd1', action: 'reused', id: uuid('db1') }); // BOM survives the writer's migrations_dir fill. expect(readFileSync(wranglerPath, 'utf-8').charCodeAt(0)).toBe(0xfeff); }); @@ -294,7 +326,7 @@ describe('provisionCloudflareResources — config discovery (fail closed, single const { run } = makeRunner([ { match: /^kv namespace list/, - reply: () => ok(JSON.stringify([{ id: 'real-kv-id', title: 'myapp-kv' }])), + reply: () => ok(JSON.stringify([{ id: hex32('kv'), title: 'myapp-kv' }])), }, ]); const result = await provisionCloudflareResources( @@ -306,7 +338,7 @@ describe('provisionCloudflareResources — config discovery (fail closed, single expect(result.configPath).toBe(jsonPath); // The adopted id landed in the passed file (wrangler.json) — NOT split into .jsonc. const jsonAfter = parseJsoncRaw(readFileSync(jsonPath, 'utf-8')) as Record; - expect((jsonAfter.kv_namespaces as Array>)[0].id).toBe('real-kv-id'); + expect((jsonAfter.kv_namespaces as Array>)[0].id).toBe(hex32('kv')); // wrangler.jsonc is untouched (still the placeholder). const jsoncAfter = parseJsoncRaw(readFileSync(jsoncPath, 'utf-8')) as Record; expect((jsoncAfter.kv_namespaces as Array>)[0].id).toBe('local'); @@ -324,7 +356,7 @@ describe('provisionCloudflareResources — D1', () => { simulateUpdateConfig('d1_databases', { binding: 'DB', database_name: 'myapp-db', - database_id: 'created-uuid', + database_id: uuid('created'), }); return ok(); }, @@ -355,7 +387,7 @@ describe('provisionCloudflareResources — D1', () => { { binding: 'DB', database_name: 'myapp-db', - database_id: 'created-uuid', + database_id: uuid('created'), migrations_dir: './db/migrations', }, ]); @@ -370,7 +402,7 @@ describe('provisionCloudflareResources — D1', () => { // fill migrations_dir — without re-creating (no duplicate D1). setup( JSON.stringify({ - d1_databases: [{ binding: 'DB', database_name: 'myapp-db', database_id: 'real-uuid-1' }], + d1_databases: [{ binding: 'DB', database_name: 'myapp-db', database_id: uuid('db1') }], }), ); const { run, calls } = makeRunner([]); @@ -386,11 +418,11 @@ describe('provisionCloudflareResources — D1', () => { expect(d1[0]).toEqual({ binding: 'DB', database_name: 'myapp-db', - database_id: 'real-uuid-1', + database_id: uuid('db1'), migrations_dir: './db/migrations', }); expect(result.resources).toEqual([ - { kind: 'd1', binding: 'DB', name: 'myapp-db', action: 'reused', id: 'real-uuid-1' }, + { kind: 'd1', binding: 'DB', name: 'myapp-db', action: 'reused', id: uuid('db1') }, ]); }); @@ -403,7 +435,7 @@ describe('provisionCloudflareResources — D1', () => { const { run, calls } = makeRunner([ { match: /^d1 list --json/, - reply: () => ok(JSON.stringify([{ uuid: 'existing-uuid', name: 'myapp-db' }])), + reply: () => ok(JSON.stringify([{ uuid: uuid('existing'), name: 'myapp-db' }])), }, ]); const result = await provisionCloudflareResources( @@ -419,12 +451,12 @@ describe('provisionCloudflareResources — D1', () => { { binding: 'DB', database_name: 'myapp-db', - database_id: 'existing-uuid', + database_id: uuid('existing'), migrations_dir: './db/migrations', }, ]); expect(result.resources).toEqual([ - { kind: 'd1', binding: 'DB', name: 'myapp-db', action: 'adopted', id: 'existing-uuid' }, + { kind: 'd1', binding: 'DB', name: 'myapp-db', action: 'adopted', id: uuid('existing') }, ]); }); @@ -442,7 +474,7 @@ describe('provisionCloudflareResources — D1', () => { }, { match: /^d1 info myapp-db --json/, - reply: () => ok(JSON.stringify({ uuid: 'raced-uuid', name: 'myapp-db' })), + reply: () => ok(JSON.stringify({ uuid: uuid('raced'), name: 'myapp-db' })), }, ]); const result = await provisionCloudflareResources( @@ -467,13 +499,13 @@ describe('provisionCloudflareResources — D1', () => { ['d1', 'info', 'myapp-db', '--json', '-c', wranglerPath, '--env='], ]); const d1 = readConfig().d1_databases as Array>; - expect(d1[0]).toMatchObject({ binding: 'DB', database_id: 'raced-uuid' }); + expect(d1[0]).toMatchObject({ binding: 'DB', database_id: uuid('raced') }); expect(result.resources[0]).toEqual({ kind: 'd1', binding: 'DB', name: 'myapp-db', action: 'adopted', - id: 'raced-uuid', + id: uuid('raced'), }); }); @@ -612,7 +644,7 @@ describe('provisionCloudflareResources — KV', () => { const { run, calls } = makeRunner([ { match: /^kv namespace list/, - reply: () => ok(JSON.stringify([{ id: 'real-kv-id', title: 'myapp-kv' }])), + reply: () => ok(JSON.stringify([{ id: hex32('kv'), title: 'myapp-kv' }])), }, ]); const result = await provisionCloudflareResources( @@ -623,9 +655,9 @@ describe('provisionCloudflareResources — KV', () => { ); expect(calls).toEqual([['kv', 'namespace', 'list', '-c', wranglerPath, '--env=']]); const kv = readConfig().kv_namespaces as Array>; - expect(kv).toEqual([{ binding: 'KV', id: 'real-kv-id' }]); + expect(kv).toEqual([{ binding: 'KV', id: hex32('kv') }]); expect(result.resources).toEqual([ - { kind: 'kv', binding: 'KV', name: 'myapp-kv', action: 'adopted', id: 'real-kv-id' }, + { kind: 'kv', binding: 'KV', name: 'myapp-kv', action: 'adopted', id: hex32('kv') }, ]); }); @@ -636,7 +668,7 @@ describe('provisionCloudflareResources — KV', () => { { match: /^kv namespace create /, reply: () => { - simulateUpdateConfig('kv_namespaces', { binding: 'ISR_CACHE', id: 'new-kv-id' }); + simulateUpdateConfig('kv_namespaces', { binding: 'ISR_CACHE', id: hex32('newkv') }); return ok(); }, }, @@ -727,9 +759,9 @@ describe('provisionCloudflareResources — KV', () => { reply: () => ok( JSON.stringify([ - { id: 'zzz', title: 'myapp-kv' }, - { id: 'aaa', title: 'myapp-kv' }, - { id: 'mmm', title: 'other' }, + { id: hex32('zzz'), title: 'myapp-kv' }, + { id: hex32('aaa'), title: 'myapp-kv' }, + { id: hex32('mmm'), title: 'other' }, ]), ), }, @@ -740,11 +772,12 @@ describe('provisionCloudflareResources — KV', () => { { ...emptyDesired, kvNamespaces: [{ binding: 'KV', title: 'myapp-kv' }] }, { runWrangler: run, log: { info: () => {}, warn: (m) => warnings.push(m) } }, ); - expect(result.resources[0]).toMatchObject({ action: 'adopted', id: 'aaa' }); - expect((readConfig().kv_namespaces as Array>)[0].id).toBe('aaa'); - expect(warnings.some((w) => w.includes('multiple KV namespaces') && w.includes('aaa'))).toBe( - true, - ); + // hex32 is order-preserving on equal-length seeds, so hex32('aaa') < hex32('zzz'). + expect(result.resources[0]).toMatchObject({ action: 'adopted', id: hex32('aaa') }); + expect((readConfig().kv_namespaces as Array>)[0].id).toBe(hex32('aaa')); + expect( + warnings.some((w) => w.includes('multiple KV namespaces') && w.includes(hex32('aaa'))), + ).toBe(true); }); it('provisions each KV binding independently (ISR_CACHE + app KV)', async () => { @@ -755,7 +788,7 @@ describe('provisionCloudflareResources — KV', () => { match: /^kv namespace create /, reply: (sub) => { const binding = sub.includes('ISR_CACHE') ? 'ISR_CACHE' : 'KV'; - simulateUpdateConfig('kv_namespaces', { binding, id: `${binding}-id` }); + simulateUpdateConfig('kv_namespaces', { binding, id: hex32(binding) }); return ok(); }, }, @@ -822,7 +855,7 @@ describe('provisionCloudflareResources — idempotent re-run', () => { const first = makeRunner([ { match: /^kv namespace list/, - reply: () => ok(JSON.stringify([{ id: 'real-kv-id', title: 'myapp-kv' }])), + reply: () => ok(JSON.stringify([{ id: hex32('kv'), title: 'myapp-kv' }])), }, ]); const desired: DesiredResourceSet = { @@ -842,7 +875,7 @@ describe('provisionCloudflareResources — idempotent re-run', () => { }); expect(second.calls).toEqual([]); expect(result.resources).toEqual([ - { kind: 'kv', binding: 'KV', name: 'myapp-kv', action: 'reused', id: 'real-kv-id' }, + { kind: 'kv', binding: 'KV', name: 'myapp-kv', action: 'reused', id: hex32('kv') }, ]); expect(readFileSync(wranglerPath, 'utf-8')).toBe(afterFirst); }); @@ -985,7 +1018,7 @@ describe('provisionCloudflareResources — R2', () => { describe('provisionCloudflareResources — Hyperdrive', () => { it('reuses when the hyperdrive binding already has a real id (no wrangler call)', async () => { - setup(JSON.stringify({ hyperdrive: [{ binding: 'HYPERDRIVE', id: 'real-hd-id' }] })); + setup(JSON.stringify({ hyperdrive: [{ binding: 'HYPERDRIVE', id: hex32('hd') }] })); const { run, calls } = makeRunner([]); const result = await provisionCloudflareResources( tmpDir, @@ -1003,7 +1036,7 @@ describe('provisionCloudflareResources — Hyperdrive', () => { binding: 'HYPERDRIVE', name: 'myapp-hd', action: 'reused', - id: 'real-hd-id', + id: hex32('hd'), }, ]); }); @@ -1023,21 +1056,21 @@ describe('provisionCloudflareResources — Hyperdrive', () => { log: silentLog, listHyperdriveConfigs: async () => [ { id: 'other-hd', name: 'someone-else' }, - { id: 'existing-hd-id', name: 'myapp-hd' }, + { id: hex32('existing'), name: 'myapp-hd' }, ], }, ); // No wrangler create — existence via the injected REST seam, id via the writer. expect(calls.some((c) => isCall(c, 'hyperdrive', 'create'))).toBe(false); const hd = readConfig().hyperdrive as Array>; - expect(hd).toEqual([{ binding: 'HYPERDRIVE', id: 'existing-hd-id' }]); + expect(hd).toEqual([{ binding: 'HYPERDRIVE', id: hex32('existing') }]); expect(result.resources).toEqual([ { kind: 'hyperdrive', binding: 'HYPERDRIVE', name: 'myapp-hd', action: 'adopted', - id: 'existing-hd-id', + id: hex32('existing'), }, ]); }); @@ -1048,7 +1081,7 @@ describe('provisionCloudflareResources — Hyperdrive', () => { { match: /^hyperdrive create /, reply: () => { - simulateUpdateConfig('hyperdrive', { binding: 'HYPERDRIVE', id: 'new-hd-id' }); + simulateUpdateConfig('hyperdrive', { binding: 'HYPERDRIVE', id: hex32('newhd') }); return ok(); }, }, @@ -1388,7 +1421,7 @@ describe('provisionCloudflareResources — account reconciliation + config pin ( simulateUpdateConfig('d1_databases', { binding: 'DB', database_name: 'myapp-db', - database_id: 'created-under-A', + database_id: uuid('createdA'), }); return ok(); }, @@ -1411,7 +1444,7 @@ describe('provisionCloudflareResources — account reconciliation + config pin ( const after1 = readConfig(); expect(after1.account_id).toBe('acct-A'); expect((after1.d1_databases as Array>)[0].database_id).toBe( - 'created-under-A', + uuid('createdA'), ); // Run 2 retries under account B → must fail closed at reconciliation with NO @@ -1449,7 +1482,7 @@ describe('provisionCloudflareResources — account reconciliation + config pin ( simulateUpdateConfig('d1_databases', { binding: 'DB', database_name: 'myapp-db', - database_id: 'created-uuid', + database_id: uuid('created'), }); return ok(); }, @@ -1458,7 +1491,7 @@ describe('provisionCloudflareResources — account reconciliation + config pin ( { match: /^kv namespace create /, reply: () => { - simulateUpdateConfig('kv_namespaces', { binding: 'KV', id: 'new-kv-id' }); + simulateUpdateConfig('kv_namespaces', { binding: 'KV', id: hex32('newkv') }); return ok(); }, }, @@ -1466,7 +1499,7 @@ describe('provisionCloudflareResources — account reconciliation + config pin ( { match: /^r2 bucket create /, reply: () => { - simulateUpdateConfig('r2_buckets', { binding: 'STORAGE', bucket_name: 'b' }); + simulateUpdateConfig('r2_buckets', { binding: 'STORAGE', bucket_name: 'bucket-b' }); return ok(); }, }, @@ -1490,7 +1523,7 @@ describe('provisionCloudflareResources — account reconciliation + config pin ( ...emptyDesired, d1: { binding: 'DB', name: 'myapp-db' }, kvNamespaces: [{ binding: 'KV', title: 'myapp-kv' }], - r2: { binding: 'STORAGE', bucketName: 'b' }, + r2: { binding: 'STORAGE', bucketName: 'bucket-b' }, queues: ['jobs'], }, { runWrangler: run, log: silentLog }, @@ -1582,7 +1615,7 @@ describe('provisionCloudflareResources — Hyperdrive discovery (F2 pagination + { body: { success: true, - result: [{ id: 'p2-hd', name: 'myapp-hd' }], + result: [{ id: hex32('p2hd'), name: 'myapp-hd' }], result_info: { total_count: 2 }, }, }, @@ -1601,8 +1634,8 @@ describe('provisionCloudflareResources — Hyperdrive discovery (F2 pagination + expect(urls.length).toBe(2); // both pages fetched expect(calls.some((c) => isCall(c, 'hyperdrive', 'create'))).toBe(false); const hd = readConfig().hyperdrive as Array>; - expect(hd).toEqual([{ binding: 'HYPERDRIVE', id: 'p2-hd' }]); - expect(result.resources[0]).toMatchObject({ action: 'adopted', id: 'p2-hd' }); + expect(hd).toEqual([{ binding: 'HYPERDRIVE', id: hex32('p2hd') }]); + expect(result.resources[0]).toMatchObject({ action: 'adopted', id: hex32('p2hd') }); } finally { restore(); } @@ -1939,7 +1972,7 @@ describe('provisionCloudflareResources — F1 account gate + env scrub (round 4) simulateUpdateConfig('d1_databases', { binding: 'DB', database_name: 'myapp-db', - database_id: 'created-under-A', + database_id: uuid('createdA'), }); return ok(); }, @@ -1960,7 +1993,7 @@ describe('provisionCloudflareResources — F1 account gate + env scrub (round 4) const after1 = readConfig(); expect(after1.account_id).toBe('ambient-A'); expect((after1.d1_databases as Array>)[0].database_id).toBe( - 'created-under-A', + uuid('createdA'), ); // Run 2 retries under account B → reconciliation A≠B fails closed, NO @@ -2003,22 +2036,24 @@ describe('provisionCloudflareResources — F2 resource-name validation (round 4) createPrefix: Array; }> = [ { - label: /invalid D1 database name/, + // badNames all start with "-", so the universal option-injection guard fires FIRST + // (F5 split) with "refusing to pass an unsafe