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, + }); + }, +);