From f504c2a351027f17ac353fba2d7cfc3505ee17d6 Mon Sep 17 00:00:00 2001 From: yi111 <153097222+Yi-111-a@users.noreply.github.com> Date: Sat, 10 Oct 2026 06:38:47 +0800 Subject: [PATCH] fix(javascript): decline source-map UNC references with an unusable host (#1372) * fix(javascript): decline source-map UNC references with an unusable host A source map can name a Windows share whose host a file URL cannot carry, such as `\\bad host\share\a.js`. On the Node builds `engines` admits, `pathToFileURL(path, { windows: true })` fails a native assertion and calls `abort()` on Node 22.19-22.22, 24.11-24.15 and 26.0, so the process dies with exit 134 instead of declining the reference. Node 22.23 and 24.16 raise `ERR_INVALID_URL` instead, and older builds silently drop the host, so the same reference resolves three different ways depending on the runtime. Validate the UNC host before handing the path to Node. A host carrying a forbidden host code point, a percent escape or nothing at all now resolves as `unresolved` on every admitted build, and `npm test` passes the existing `\\bad host\share\a.js` row everywhere. Well-formed UNC hosts and drive-letter paths are untouched. * fix(javascript): validate UNC hosts through IDNA before conversion Reject Unicode source-map UNC hosts that URL/IDNA processing cannot use or normalizes to an empty hostname before calling Windows pathToFileURL. Keep the original source identity and preserve usable normalized hosts. Exercise the built reference resolver in owned subprocesses, including U+00AD and U+200C as source names and source roots. The original PR implementation aborts under Node 22.22.2; the new regression isolates those signals from the test runner. Add this test to the existing Node 22 Windows CI step. Validation: 64 focused tests pass on Node 22.22.2 and 24.18.0; npm run check and test discovery pass. Local verification ran on macOS; Windows verification remains with CI. --------- Co-authored-by: Yi-111-a Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- .github/workflows/ci.yml | 3 +- .../javascript/javascriptSourceMapPaths.ts | 17 +++- .../process/sourceMapUncHosts.test.ts | 92 +++++++++++++++++++ 3 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 tests/boundary/process/sourceMapUncHosts.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6755bb04c..503d44f16 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -460,12 +460,13 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22.19.0 - - name: Verify native directory identity on Node 22 + - name: Verify native path boundaries on Node 22 run: >- npx vitest run --coverage.enabled=false tests/boundary/filesystem/directoryArtifactReaderOpen.test.ts tests/boundary/filesystem/javascriptPackagePrecedence.test.ts tests/boundary/filesystem/javascriptArtifactContentIdentity.test.ts + tests/boundary/process/sourceMapUncHosts.test.ts - uses: actions/upload-artifact@v4 if: always() with: diff --git a/src/domain/javascript/javascriptSourceMapPaths.ts b/src/domain/javascript/javascriptSourceMapPaths.ts index 33e59454f..d897bdd26 100644 --- a/src/domain/javascript/javascriptSourceMapPaths.ts +++ b/src/domain/javascript/javascriptSourceMapPaths.ts @@ -1,5 +1,5 @@ import { posix } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; +import { domainToASCII, fileURLToPath, pathToFileURL } from "node:url"; import { z } from "zod"; @@ -101,7 +101,22 @@ const resolveJavaScriptSourceMapPath = ( return { path: value, kind: relative ? "artifact-relative" : "suffix" }; }; +// Reject URL delimiters/escapes before IDNA can truncate or decode the host. +const unusableHost = /[\0\t\n\r #/:<>?@[\\\]^|%]/u; + +// URL/IDNA validation also rejects Unicode hosts that normalize to empty. +const carriesUsableHost = (host: string): boolean => + !unusableHost.test(host) && domainToASCII(host) !== ""; + const windowsFileUrl = (path: string): URL | null => { + // A malformed UNC host aborts pathToFileURL() on the Node builds this + // package admits, so decline the reference before handing the path over + // rather than trusting a TypeError that never arrives. + if ( + path.startsWith("\\\\") && + !carriesUsableHost(path.slice(2).split("\\")[0] ?? "") + ) + return null; try { return pathToFileURL(path, { windows: true }); } catch (cause: unknown) { diff --git a/tests/boundary/process/sourceMapUncHosts.test.ts b/tests/boundary/process/sourceMapUncHosts.test.ts new file mode 100644 index 000000000..293a3f71a --- /dev/null +++ b/tests/boundary/process/sourceMapUncHosts.test.ts @@ -0,0 +1,92 @@ +import { expect } from "vitest"; + +import { processTest } from "../../support/process/processFixture.js"; + +const cases = [ + { + name: "soft hyphen U+00AD", + source: "\\\\\u00ad\\share\\source.ts", + expected: { kind: "unresolved" }, + }, + { + name: "zero width non joiner U+200C", + source: "\\\\\u200c\\share\\source.ts", + expected: { kind: "unresolved" }, + }, + { + name: "ASCII delimiter", + source: "\\\\bad#host\\share\\source.ts", + expected: { kind: "unresolved" }, + }, + { + name: "empty host", + source: "\\\\\\share\\source.ts", + expected: { kind: "unresolved" }, + }, + { + name: "normal host", + source: "\\\\server\\share\\source.ts", + expected: { kind: "suffix", path: "server/share/source.ts" }, + }, + { + name: "valid IDNA host", + source: "\\\\bücher.example\\share\\source.ts", + expected: { kind: "suffix", path: "bücher.example/share/source.ts" }, + }, + { + name: "nonempty normalized host", + source: "\\\\\u00adserver\\share\\source.ts", + expected: { kind: "suffix", path: "server/share/source.ts" }, + }, + { + name: "localhost host", + source: "\\\\localhost\\share\\source.ts", + expected: { kind: "suffix", path: "share/source.ts" }, + }, + { + name: "drive letter", + source: "C:\\src\\source.ts", + expected: { kind: "suffix", path: "C:/src/source.ts" }, + }, + { + name: "soft hyphen source root", + source: "source.ts", + root: "\\\\\u00ad\\share", + expected: { kind: "unresolved" }, + }, + { + name: "zero width non joiner source root", + source: "source.ts", + root: "\\\\\u200c\\share", + expected: { kind: "unresolved" }, + }, +] as const; + +processTest.for(cases)( + "resolves $name in an owned subprocess without a native abort", + async (scenario, { processes }) => { + const sourceRoot = "root" in scenario ? scenario.root : null; + // Import the built production helper in a child. A native assertion must + // become a failed signal assertion here, never terminate the Vitest worker. + const result = await processes.run( + process.execPath, + [ + "--input-type=module", + "--eval", + `import { resolveJavaScriptSourceMapReference } from "./dist/domain/javascript/javascriptSourceMapPaths.js"; + const [source, root] = JSON.parse(process.argv[1]); + console.log(JSON.stringify(resolveJavaScriptSourceMapReference(source, root, "maps/main.js.map")));`, + JSON.stringify([scenario.source, sourceRoot]), + ], + { timeoutMs: 10_000 }, + ); + expect(result.signal, result.stderr).toBeNull(); + expect(result.exitCode, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ + source_name: scenario.source, + source_root: sourceRoot, + map_path: "maps/main.js.map", + resolution: scenario.expected, + }); + }, +);