From d2fe7a478e70cbde6e600a7ca6dcd3a8c83f9de3 Mon Sep 17 00:00:00 2001 From: Daniel Fry Date: Thu, 2 Jul 2026 22:44:07 +0100 Subject: [PATCH 1/2] fix(hardening): prerelease peer versions, asset-stub escaping, Flow-strip guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - validate.ts: strip prerelease/build metadata before version comparison — "4.0.0-beta.3" parsed to [4, NaN, …] and failed the minimum check, throwing the unsupported-peer startup error for installs running betas/RCs (exactly the early-adopter cohort). A prerelease of the minimum itself is accepted. - plugin.ts asset stubs: case-insensitive extension matching (the native loader already lowercases; the engines must agree on what is an asset), regex-escaped user-supplied assetExts entries, and JSON-stringified basenames so filenames containing quotes emit valid JS (mirrors the native loader, which already stringifies). - plugin.ts Flow-strip: the "@flow" content filter is a heuristic — the marker can appear inside a string or comment of a file flow-remove-types cannot parse; that parse error previously propagated and killed the whole transform pipeline. Unparseable files now pass through untouched (a genuine Flow file that fails here would fail Vite's own parse next with a clearer error). --- .changeset/validation-asset-hardening.md | 9 +++ packages/vitest-native/src/plugin.ts | 32 ++++++-- packages/vitest-native/src/validate.ts | 6 ++ .../tests/plugin-hardening.test.ts | 78 +++++++++++++++++++ .../vitest-native/tests/validation.test.ts | 49 ++++++++++++ 5 files changed, 166 insertions(+), 8 deletions(-) create mode 100644 .changeset/validation-asset-hardening.md create mode 100644 packages/vitest-native/tests/plugin-hardening.test.ts diff --git a/.changeset/validation-asset-hardening.md b/.changeset/validation-asset-hardening.md new file mode 100644 index 0000000..bed1c99 --- /dev/null +++ b/.changeset/validation-asset-hardening.md @@ -0,0 +1,9 @@ +--- +"vitest-native": patch +--- + +Three hardening fixes: + +- **Prerelease peer versions no longer fail validation.** `4.0.0-beta.3` parsed to `[4, NaN, …]` and failed the minimum check, hard-erroring at startup for installs running betas/RCs. Prerelease/build metadata is now stripped before comparison; a prerelease of the minimum itself is accepted. +- **Mock-engine asset stubs match the native loader's semantics.** The extension match is now case-insensitive (`LOGO.PNG` stubs like `logo.png`), user-supplied `assetExts` entries are regex-escaped, and the stubbed basename is JSON-stringified so filenames containing quotes emit valid JS. +- **The mock engine's Flow-strip transform skips unparseable files instead of throwing.** The `@flow` filter is a heuristic — the marker can appear inside a string or comment of a file `flow-remove-types` then fails to parse; that parse error previously took down the whole transform pipeline. diff --git a/packages/vitest-native/src/plugin.ts b/packages/vitest-native/src/plugin.ts index f82c8d8..86f1620 100644 --- a/packages/vitest-native/src/plugin.ts +++ b/packages/vitest-native/src/plugin.ts @@ -608,8 +608,12 @@ export function reactNative(options?: VitestNativeOptions): Plugin { } } - // Build the asset regex from the resolved extensions list. - assetPattern = new RegExp(`\\.(${resolved.assetExts.join("|")})$`); + // Build the asset regex from the resolved extensions list. Extensions are + // escaped (user-supplied entries may contain regex metacharacters) and the + // match is case-insensitive ("LOGO.PNG" is an asset too — the native + // loader already lowercases; the engines must agree). + const escaped = resolved.assetExts.map((e) => e.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); + assetPattern = new RegExp(`\\.(${escaped.join("|")})$`, "i"); }, resolveId(source, importer) { @@ -724,10 +728,13 @@ export function reactNative(options?: VitestNativeOptions): Plugin { } // Asset imports have the same stable semantics under both engines. + // JSON.stringify the basename — filenames can contain quotes/backslashes, + // and raw interpolation would emit broken JS (same hazard class as the + // native loader, which already stringifies). const fsPath = stripFsPrefix(id); if (assetPattern.test(fsPath)) { const basename = fsPath.split("/").pop() ?? fsPath; - return `export default "${basename}";`; + return `export default ${JSON.stringify(basename)};`; } // Native engine serves RN from Node's CJS graph — nothing else to load here. @@ -782,11 +789,20 @@ export function reactNative(options?: VitestNativeOptions): Plugin { if (!id.includes("react-native") || !id.endsWith(".js")) return undefined; if (!code.includes("@flow")) return undefined; - const stripped = flowRemoveTypes(code, { all: true }); - return { - code: stripped.toString(), - map: stripped.generateMap(), - }; + // The filters above are heuristics — "@flow" can appear inside a string + // or comment of a perfectly valid non-Flow file that flowRemoveTypes + // then fails to parse. Skipping is strictly better than throwing: a + // genuine Flow file that fails here would fail Vite's own parse next + // with a clearer error, while a false positive passes through untouched. + try { + const stripped = flowRemoveTypes(code, { all: true }); + return { + code: stripped.toString(), + map: stripped.generateMap(), + }; + } catch { + return undefined; + } }, }; } diff --git a/packages/vitest-native/src/validate.ts b/packages/vitest-native/src/validate.ts index d5e9934..b27ebad 100644 --- a/packages/vitest-native/src/validate.ts +++ b/packages/vitest-native/src/validate.ts @@ -83,9 +83,15 @@ export function validateOptions(options: Record): void { } function satisfiesMinimum(version: string, minimum: string): boolean { + // Strip prerelease/build metadata before splitting — "4.0.0-beta.3" must + // parse as [4,0,0], not [4,NaN,…] (NaN comparisons made every prerelease + // fail the minimum check, hard-erroring at startup for exactly the + // early-adopter installs that run betas). A prerelease of the minimum + // itself is accepted. const parse = (v: string) => v .replace(/^[^0-9]*/, "") + .split(/[-+]/)[0] .split(".") .map(Number); const [aMaj, aMin = 0, aPat = 0] = parse(version); diff --git a/packages/vitest-native/tests/plugin-hardening.test.ts b/packages/vitest-native/tests/plugin-hardening.test.ts new file mode 100644 index 0000000..f1de2d4 --- /dev/null +++ b/packages/vitest-native/tests/plugin-hardening.test.ts @@ -0,0 +1,78 @@ +/** + * Hardening regressions for the plugin's asset stubbing and Flow-strip + * transform (mock engine). + */ +import { describe, it, expect } from "vitest"; +import path from "node:path"; +import fs from "node:fs"; +import { fileURLToPath } from "node:url"; +import { reactNative } from "../src/index.js"; +import { gestureHandler } from "../src/presets/index.js"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +function findUp(rel: string, start: string): string { + let dir = start; + for (;;) { + const candidate = path.join(dir, rel); + if (fs.existsSync(candidate)) return candidate; + const parent = path.dirname(dir); + if (parent === dir) throw new Error(`${rel} not found from ${start}`); + dir = parent; + } +} +const projectRoot = path.dirname(findUp("package.json", HERE)); +const SERVE_ENV = { command: "serve", mode: "test" } as const; + +async function makePlugin() { + const plugin = reactNative({ engine: "mock", presets: [gestureHandler()] }) as any; + await plugin.config({ root: projectRoot }, SERVE_ENV); + await plugin.configResolved({ root: projectRoot }); + return plugin; +} + +describe("asset stubbing (mock engine)", () => { + it("matches asset extensions case-insensitively, like the native loader", async () => { + const plugin = await makePlugin(); + expect(plugin.load("/proj/assets/LOGO.PNG")).toBe(`export default "LOGO.PNG";`); + expect(plugin.load("/proj/assets/Icon.TTF")).toBe(`export default "Icon.TTF";`); + }); + + it("emits valid JS for basenames containing quotes", async () => { + const plugin = await makePlugin(); + const code = plugin.load(`/proj/assets/we"ird.png`); + // JSON.stringify-escaped — raw interpolation would emit a syntax error here. + expect(code).toBe(`export default ${JSON.stringify(`we"ird.png`)};`); + }); +}); + +describe("Flow-strip transform guard (mock engine)", () => { + it("still strips genuine Flow sources in react-native ecosystem packages", async () => { + const plugin = await makePlugin(); + const result = plugin.transform( + `// @flow\ntype Props = { x: number };\nmodule.exports = function f(p: Props) { return p.x; };`, + "/proj/node_modules/react-native-thing/lib/f.js", + ); + expect(result).toBeTruthy(); + expect(result.code).not.toContain("Props"); + expect(result.code).toContain("return p.x"); + }); + + it("skips files the stripper cannot parse instead of throwing", async () => { + const plugin = await makePlugin(); + // "@flow" appears in a string of a file that is not valid input for the + // stripper — previously this threw and took down the whole transform + // pipeline; now it passes through untouched. + expect(() => + plugin.transform( + `const marker = "@flow"; const = broken;`, + "/proj/node_modules/react-native-thing/lib/broken.js", + ), + ).not.toThrow(); + expect( + plugin.transform( + `const marker = "@flow"; const = broken;`, + "/proj/node_modules/react-native-thing/lib/broken.js", + ), + ).toBeUndefined(); + }); +}); diff --git a/packages/vitest-native/tests/validation.test.ts b/packages/vitest-native/tests/validation.test.ts index bd73a5e..d6b54e8 100644 --- a/packages/vitest-native/tests/validation.test.ts +++ b/packages/vitest-native/tests/validation.test.ts @@ -1,4 +1,7 @@ import { describe, it, expect, vi } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { validatePeerDependency } from "../src/validate.js"; import { reactNative } from "../src/index.js"; @@ -26,6 +29,52 @@ describe("validatePeerDependency", () => { }); expect(result).toBeNull(); }); + + // Prerelease versions previously parsed to NaN ("4.0.0-beta.3" → [4, NaN, …]) + // and failed the minimum check — a hard startup error for exactly the + // early-adopter installs that run betas/RCs. + describe("prerelease versions", () => { + function withFixture(version: string, fn: (root: string) => void): void { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "vn-prerelease-")); + try { + const pkgDir = path.join(tmp, "node_modules", "fixture-pkg"); + fs.mkdirSync(pkgDir, { recursive: true }); + fs.writeFileSync(path.join(tmp, "package.json"), JSON.stringify({ name: "consumer" })); + fs.writeFileSync( + path.join(pkgDir, "package.json"), + JSON.stringify({ name: "fixture-pkg", version, main: "index.js" }), + ); + fs.writeFileSync(path.join(pkgDir, "index.js"), "module.exports = {};"); + fn(tmp); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + } + + it("accepts a prerelease of a version above the minimum", () => { + withFixture("4.1.0-beta.3", (root) => { + expect(validatePeerDependency("fixture-pkg", "4.0.0", root)).toBeNull(); + }); + }); + + it("accepts a prerelease of the minimum itself", () => { + withFixture("4.0.0-beta.3", (root) => { + expect(validatePeerDependency("fixture-pkg", "4.0.0", root)).toBeNull(); + }); + }); + + it("still rejects a prerelease below the minimum", () => { + withFixture("3.9.0-rc.1", (root) => { + expect(validatePeerDependency("fixture-pkg", "4.0.0", root)).toContain("requires"); + }); + }); + + it("still rejects a prerelease of the excluded next major", () => { + withFixture("5.0.0-alpha.1", (root) => { + expect(validatePeerDependency("fixture-pkg", "4.0.0", root, 5)).toContain("supports"); + }); + }); + }); }); describe("engine option", () => { From 2a24b29800afb617dde1c1e146c8b633a2c49bc1 Mon Sep 17 00:00:00 2001 From: Daniel Fry Date: Fri, 3 Jul 2026 08:25:15 +0100 Subject: [PATCH 2/2] review: diagnostics warning on skipped Flow strip, precise prerelease-bug description, property-based test assertions Pre-PR review findings: the silent catch now warns under diagnostics with the file id (a genuine Flow file the stripper chokes on otherwise surfaces only as a downstream Vite parse error with no hint the strip was attempted); the comment/changeset overstated the old parse bug (NaN landed in the patch slot, so only prereleases sharing the minimum's major.minor failed, not all prereleases); the quote-escaping test now asserts the emitted literal round-trips rather than mirroring the implementation expression. --- .changeset/validation-asset-hardening.md | 2 +- packages/vitest-native/src/plugin.ts | 7 +++++- packages/vitest-native/src/validate.ts | 13 ++++++----- .../tests/plugin-hardening.test.ts | 22 +++++++++---------- 4 files changed, 25 insertions(+), 19 deletions(-) diff --git a/.changeset/validation-asset-hardening.md b/.changeset/validation-asset-hardening.md index bed1c99..09a204b 100644 --- a/.changeset/validation-asset-hardening.md +++ b/.changeset/validation-asset-hardening.md @@ -4,6 +4,6 @@ Three hardening fixes: -- **Prerelease peer versions no longer fail validation.** `4.0.0-beta.3` parsed to `[4, NaN, …]` and failed the minimum check, hard-erroring at startup for installs running betas/RCs. Prerelease/build metadata is now stripped before comparison; a prerelease of the minimum itself is accepted. +- **Prerelease peer versions no longer fail validation.** A prerelease sharing the minimum's major.minor (e.g. vitest `4.0.0-beta.3` against the `4.0.0` floor) parsed with `NaN` in the patch slot, failed the minimum check, and hard-errored at startup for installs running betas/RCs. Prerelease/build metadata is now stripped before comparison; a prerelease of the minimum itself is accepted. - **Mock-engine asset stubs match the native loader's semantics.** The extension match is now case-insensitive (`LOGO.PNG` stubs like `logo.png`), user-supplied `assetExts` entries are regex-escaped, and the stubbed basename is JSON-stringified so filenames containing quotes emit valid JS. - **The mock engine's Flow-strip transform skips unparseable files instead of throwing.** The `@flow` filter is a heuristic — the marker can appear inside a string or comment of a file `flow-remove-types` then fails to parse; that parse error previously took down the whole transform pipeline. diff --git a/packages/vitest-native/src/plugin.ts b/packages/vitest-native/src/plugin.ts index 86f1620..ca224aa 100644 --- a/packages/vitest-native/src/plugin.ts +++ b/packages/vitest-native/src/plugin.ts @@ -800,7 +800,12 @@ export function reactNative(options?: VitestNativeOptions): Plugin { code: stripped.toString(), map: stripped.generateMap(), }; - } catch { + } catch (e) { + if (diagnostics) { + console.warn( + `[vitest-native] Flow strip skipped for ${id} (parse failed: ${(e as Error)?.message}); serving the file untouched.`, + ); + } return undefined; } }, diff --git a/packages/vitest-native/src/validate.ts b/packages/vitest-native/src/validate.ts index b27ebad..5a135da 100644 --- a/packages/vitest-native/src/validate.ts +++ b/packages/vitest-native/src/validate.ts @@ -83,11 +83,14 @@ export function validateOptions(options: Record): void { } function satisfiesMinimum(version: string, minimum: string): boolean { - // Strip prerelease/build metadata before splitting — "4.0.0-beta.3" must - // parse as [4,0,0], not [4,NaN,…] (NaN comparisons made every prerelease - // fail the minimum check, hard-erroring at startup for exactly the - // early-adopter installs that run betas). A prerelease of the minimum - // itself is accepted. + // Strip prerelease/build metadata before splitting — "4.0.0-beta.3" split + // on "." as ["4","0","0-beta","3"] put NaN in the PATCH slot, so a + // prerelease sharing the minimum's major.minor (e.g. vitest 4.0.0-beta.x + // against the 4.0.0 floor) failed the check and hard-errored at startup — + // exactly the early-adopter installs that run betas. A prerelease of the + // minimum itself is accepted (deliberate: rejecting it would re-break that + // cohort; vite/vitest don't publish patch-level prereleases, so the + // security-floor bypass this theoretically allows doesn't occur on npm). const parse = (v: string) => v .replace(/^[^0-9]*/, "") diff --git a/packages/vitest-native/tests/plugin-hardening.test.ts b/packages/vitest-native/tests/plugin-hardening.test.ts index f1de2d4..bd2271b 100644 --- a/packages/vitest-native/tests/plugin-hardening.test.ts +++ b/packages/vitest-native/tests/plugin-hardening.test.ts @@ -40,8 +40,10 @@ describe("asset stubbing (mock engine)", () => { it("emits valid JS for basenames containing quotes", async () => { const plugin = await makePlugin(); const code = plugin.load(`/proj/assets/we"ird.png`); - // JSON.stringify-escaped — raw interpolation would emit a syntax error here. - expect(code).toBe(`export default ${JSON.stringify(`we"ird.png`)};`); + // Assert the PROPERTY (the emitted literal round-trips to the basename), + // not the mechanism — raw interpolation would emit a syntax error here. + const literal = code.replace(/^export default /, "").replace(/;$/, ""); + expect(JSON.parse(literal)).toBe(`we"ird.png`); }); }); @@ -62,17 +64,13 @@ describe("Flow-strip transform guard (mock engine)", () => { // "@flow" appears in a string of a file that is not valid input for the // stripper — previously this threw and took down the whole transform // pipeline; now it passes through untouched. - expect(() => - plugin.transform( + let result: unknown = "not called"; + expect(() => { + result = plugin.transform( `const marker = "@flow"; const = broken;`, "/proj/node_modules/react-native-thing/lib/broken.js", - ), - ).not.toThrow(); - expect( - plugin.transform( - `const marker = "@flow"; const = broken;`, - "/proj/node_modules/react-native-thing/lib/broken.js", - ), - ).toBeUndefined(); + ); + }).not.toThrow(); + expect(result).toBeUndefined(); }); });