diff --git a/packages/quicktype-core/src/input/JSONSchemaInput.ts b/packages/quicktype-core/src/input/JSONSchemaInput.ts index 44864296f0..9e1b8c4549 100644 --- a/packages/quicktype-core/src/input/JSONSchemaInput.ts +++ b/packages/quicktype-core/src/input/JSONSchemaInput.ts @@ -54,6 +54,7 @@ import { panic, parseJSON, } from "../support/Support"; +import { fixWindowsPath } from "../support/WindowsPaths"; import { type PrimitiveTypeKind, type TransformedStringTypeKind, @@ -143,7 +144,7 @@ function normalizeURI(uri: string | URI): URI { // JSONSchemaStore should take a URI, not a string, and if it reads from // a file it can decode by itself. if (typeof uri === "string") { - uri = new URI(uri); + uri = new URI(fixWindowsPath(uri)); } return new URI(URI.decode(uri.clone().normalize().toString())); @@ -151,7 +152,7 @@ function normalizeURI(uri: string | URI): URI { export class Ref { public static root(address: string | undefined): Ref { - const uri = definedMap(address, (a) => new URI(a)); + const uri = definedMap(address, (a) => new URI(fixWindowsPath(a))); return new Ref(uri, []); } @@ -189,7 +190,7 @@ export class Ref { } public static parse(ref: string): Ref { - return Ref.parseURI(new URI(ref), true); + return Ref.parseURI(new URI(fixWindowsPath(ref)), true); } public addressURI: URI | undefined; diff --git a/packages/quicktype-core/src/input/io/NodeIO.ts b/packages/quicktype-core/src/input/io/NodeIO.ts index 0480134006..154f24c973 100644 --- a/packages/quicktype-core/src/input/io/NodeIO.ts +++ b/packages/quicktype-core/src/input/io/NodeIO.ts @@ -8,6 +8,7 @@ import { Readable } from "readable-stream"; import { messageError } from "../../Messages"; import { panic } from "../../support/Support"; +import { filePathFromFileURI } from "../../support/WindowsPaths"; import { getStream } from "./get-stream"; @@ -100,7 +101,9 @@ export async function readableFromFileOrURL( httpHeaders?: string[], ): Promise { try { - if (isURL(fileOrURL)) { + if (fileOrURL.startsWith("file://")) { + fileOrURL = filePathFromFileURI(fileOrURL); + } else if (isURL(fileOrURL)) { const response = await globalThis.fetch(fileOrURL, { headers: parseHeaders(httpHeaders), }); diff --git a/packages/quicktype-core/src/support/WindowsPaths.ts b/packages/quicktype-core/src/support/WindowsPaths.ts new file mode 100644 index 0000000000..621bf0d5f8 --- /dev/null +++ b/packages/quicktype-core/src/support/WindowsPaths.ts @@ -0,0 +1,44 @@ +// Windows absolute paths given as schema addresses are carried through the +// address machinery as "file:" URIs (issue #2869). These two functions are +// the two halves of that protocol: JSONSchemaInput converts Windows paths to +// "file:" URIs before urijs parses them, and NodeIO converts "file:" URIs +// back to file paths when it reads them from disk. + +// Windows absolute paths are not valid URIs: urijs parses the drive letter +// of e.g. "C:\Users\me\top.schema.json" as a URI scheme (and lowercases it, +// and treats the backslashes as an opaque path), so relative refs resolve to +// bogus addresses (issue #2869). Convert drive-letter and UNC paths to +// "file:" URIs, which NodeIO knows how to read. +export function fixWindowsPath(pathOrURI: string): string { + if (/^[A-Za-z]:[/\\]/.test(pathOrURI)) { + // Drive-letter path, e.g. "C:\dir\x.json" -> "file:///C:/dir/x.json" + return `file:///${pathOrURI.replace(/\\/g, "/")}`; + } + + if (pathOrURI.startsWith("\\\\")) { + // UNC path, e.g. "\\server\share\x.json" -> "file://server/share/x.json" + return `file:${pathOrURI.replace(/\\/g, "/")}`; + } + + return pathOrURI; +} + +// We don't use `url.fileURLToPath` because its result is platform-dependent: +// drive-letter URIs must also be readable on POSIX (as paths relative to the +// working directory), which is how the tests exercise this, and Node's fs +// accepts forward slashes on Windows anyway. The addresses were URI-decoded +// when they were normalized, so there's no percent-decoding to do here. +export function filePathFromFileURI(fileURI: string): string { + const path = fileURI.slice("file://".length); + if (/^\/[A-Za-z]:\//.test(path)) { + // Drive-letter path, e.g. "file:///C:/dir/x.json" -> "C:/dir/x.json" + return path.slice(1); + } + + if (!path.startsWith("/")) { + // UNC path, e.g. "file://server/share/x.json" -> "//server/share/x.json" + return `//${path}`; + } + + return path; +} diff --git a/test/unit/windows-paths.test.ts b/test/unit/windows-paths.test.ts new file mode 100644 index 0000000000..4404186492 --- /dev/null +++ b/test/unit/windows-paths.test.ts @@ -0,0 +1,85 @@ +// Unit tests for the Windows-paths-as-"file:"-URIs protocol (issue #2869): +// fixWindowsPath converts Windows absolute schema addresses to "file:" URIs +// before urijs parses them, and filePathFromFileURI converts them back to +// file paths when NodeIO reads them from disk. The end-to-end pipeline is +// covered by windows-schema-paths.test.ts; these tests pin down the string +// mapping itself, including the inputs that must pass through untouched. + +import { + filePathFromFileURI, + fixWindowsPath, +} from "quicktype-core/dist/support/WindowsPaths"; +import { describe, expect, test } from "vitest"; + +describe("fixWindowsPath", () => { + test.each([ + // Drive-letter paths become "file:///:/..." URIs. + ["C:\\Users\\me\\top.schema.json", "file:///C:/Users/me/top.schema.json"], + ["C:/Users/me/top.schema.json", "file:///C:/Users/me/top.schema.json"], + ["c:\\dir\\x.json", "file:///c:/dir/x.json"], + ["Z:/x.json", "file:///Z:/x.json"], + // Mixed separators are normalized to forward slashes. + ["C:\\dir/sub\\x.json", "file:///C:/dir/sub/x.json"], + // Spaces survive: the address machinery works with decoded URIs. + ["C:\\My Dir\\x.json", "file:///C:/My Dir/x.json"], + // UNC paths keep the server as the URI host. + ["\\\\server\\share\\x.json", "file://server/share/x.json"], + ["\\\\server\\share\\dir\\x.json", "file://server/share/dir/x.json"], + ])("converts %j to %j", (input, expected) => { + expect(fixWindowsPath(input)).toBe(expected); + }); + + test.each([ + // POSIX and relative paths are not Windows paths. + "/home/me/top.schema.json", + "dir/top.schema.json", + "./top.schema.json", + "../top.schema.json", + "top.schema.json", + "", + // Real URIs must never be re-wrapped. + "http://example.com/x.schema.json", + "https://example.com/x.schema.json", + "file:///C:/dir/x.json", + "file://server/share/x.json", + // Drive-letter lookalikes: more than one letter is a URI scheme, + // digits are not drive letters, and "C:x" (no separator after the + // colon, a drive-relative path) is not supported. + "CC:\\x.json", + "1:\\x.json", + "C:", + "C:x.json", + // A single leading backslash is not UNC. + "\\dir\\x.json", + ])("passes %j through unchanged", (input) => { + expect(fixWindowsPath(input)).toBe(input); + }); +}); + +describe("filePathFromFileURI", () => { + test.each([ + // Drive-letter URIs lose the leading slash so fs sees "C:/...". + ["file:///C:/dir/x.json", "C:/dir/x.json"], + ["file:///c:/dir/x.json", "c:/dir/x.json"], + ["file:///C:/My Dir/x.json", "C:/My Dir/x.json"], + // UNC URIs turn the host back into a "//server/..." path. + ["file://server/share/x.json", "//server/share/x.json"], + // POSIX file URIs keep their absolute path. + ["file:///home/me/x.json", "/home/me/x.json"], + ["file:///x.json", "/x.json"], + ])("converts %j to %j", (input, expected) => { + expect(filePathFromFileURI(input)).toBe(expected); + }); +}); + +describe("round-trip", () => { + test.each([ + // filePathFromFileURI(fixWindowsPath(p)) yields the forward-slash + // form of p, which is what Node's fs accepts on every platform. + ["C:\\Users\\me\\top.schema.json", "C:/Users/me/top.schema.json"], + ["C:/Users/me/top.schema.json", "C:/Users/me/top.schema.json"], + ["\\\\server\\share\\x.json", "//server/share/x.json"], + ])("%j reads back as %j", (input, expected) => { + expect(filePathFromFileURI(fixWindowsPath(input))).toBe(expected); + }); +}); diff --git a/test/unit/windows-schema-paths.test.ts b/test/unit/windows-schema-paths.test.ts new file mode 100644 index 0000000000..8c74fe5cf4 --- /dev/null +++ b/test/unit/windows-schema-paths.test.ts @@ -0,0 +1,123 @@ +// Regression test for issue #2869: schema inputs given as Windows absolute +// paths must work. +// +// urijs parses the drive letter of a Windows absolute path such as +// "C:\Users\me\top.schema.json" as a URI *scheme* ("c:"), so the address is +// mangled (lowercased scheme, backslashes kept as an opaque path) and +// relative $refs resolve to bogus addresses like "c:///item.schema.json", +// which NodeIO then tries to fetch as HTTP URLs. The reported failures are +// "Could not fetch schema ..." and "Internal error: Defined value expected". +// +// The fix converts Windows absolute paths (drive-letter and UNC) to "file:" +// URIs before urijs sees them, and teaches NodeIO to read "file:" URIs from +// disk. On POSIX the resulting drive-letter file path ("C:/Users/me/...") is +// read relative to the working directory, which is what lets us test the +// whole pipeline on Linux CI: we create a literal "C:/Users/me/" directory +// tree in a temp dir, chdir into it, and run quicktype with the Windows-style +// path the issue reported. The fixture harness cannot cover this because its +// inputs are always plain POSIX paths. + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { + FetchingJSONSchemaStore, + InputData, + JSONSchemaInput, + quicktype, +} from "quicktype-core"; +import { describe, test } from "vitest"; + +const topLevelSchema = JSON.stringify({ + $schema: "http://json-schema.org/draft-07/schema#", + type: "array", + items: { $ref: "item.schema.json" }, +}); + +const itemSchema = JSON.stringify({ + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { name: { type: "string" } }, + required: ["name"], +}); + +interface SchemaPathCase { + description: string; + // The path passed to quicktype, as a user would type it. + schemaArg: (tempDir: string) => string; + // Where to create the schema files, relative to the temp dir. + schemaDir: string; +} + +const cases: SchemaPathCase[] = [ + { + description: "Windows absolute path with backslashes", + schemaDir: "C:/Users/quicktype", + schemaArg: () => "C:\\Users\\quicktype\\top.schema.json", + }, + { + description: "Windows absolute path with forward slashes", + schemaDir: "C:/Users/quicktype", + schemaArg: () => "C:/Users/quicktype/top.schema.json", + }, + { + // Must keep working exactly as before the fix. + description: "POSIX absolute path", + schemaDir: "posix", + schemaArg: (tempDir) => path.join(tempDir, "posix", "top.schema.json"), + }, +]; + +async function generateTypeScript(schemaURI: string): Promise { + // The same setup the CLI uses for `-s schema `. + const schemaInput = new JSONSchemaInput(new FetchingJSONSchemaStore()); + await schemaInput.addSource({ name: "TopLevel", uris: [schemaURI] }); + const inputData = new InputData(); + inputData.addInput(schemaInput); + + const result = await quicktype({ inputData, lang: "typescript" }); + return result.lines.join("\n"); +} + +describe("schema inputs given as absolute paths (issue #2869)", () => { + // Sequential, not concurrent: the cases chdir into their temp dirs. + for (const c of cases) { + test(c.description, async () => { + const tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), "quicktype-windows-paths-"), + ); + const previousCwd = process.cwd(); + try { + const schemaDir = path.join(tempDir, c.schemaDir); + fs.mkdirSync(schemaDir, { recursive: true }); + fs.writeFileSync( + path.join(schemaDir, "top.schema.json"), + topLevelSchema, + ); + fs.writeFileSync( + path.join(schemaDir, "item.schema.json"), + itemSchema, + ); + + // On POSIX the drive-letter path is read relative to the + // working directory, so the "C:/Users/quicktype" tree must + // be under the cwd. + process.chdir(tempDir); + const output = await generateTypeScript(c.schemaArg(tempDir)); + + // The item schema is only reachable through the relative + // $ref, so this asserts that $ref resolution against the + // address works, too. + if (!/name\s*:\s*string/.test(output)) { + throw new Error( + `generated output does not contain the type from the $ref'd schema:\n${output}`, + ); + } + } finally { + process.chdir(previousCwd); + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + } +});