diff --git a/packages/quicktype-core/src/input/JSONSchemaInput.ts b/packages/quicktype-core/src/input/JSONSchemaInput.ts index 44864296f..98223b284 100644 --- a/packages/quicktype-core/src/input/JSONSchemaInput.ts +++ b/packages/quicktype-core/src/input/JSONSchemaInput.ts @@ -136,6 +136,25 @@ function checkJSONSchema(x: unknown, refOrLoc: Ref | (() => Ref)): JSONSchema { const numberRegexp = /^[0-9]+$/; +// 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. +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; +} + function normalizeURI(uri: string | URI): URI { // FIXME: This is overly complicated and a bit shady. The problem is // that `normalize` will URL-escape, with the result that if we want to @@ -143,7 +162,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 +170,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 +208,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 0f5095dc4..01c675d06 100644 --- a/packages/quicktype-core/src/input/io/NodeIO.ts +++ b/packages/quicktype-core/src/input/io/NodeIO.ts @@ -39,6 +39,28 @@ function parseHeaders(httpHeaders?: string[]): HttpHeaders { }, {} as HttpHeaders); } +// "file:" URIs come from JSONSchemaInput, which converts Windows absolute +// schema paths to them because they are not valid URIs (issue #2869). 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. +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; +} + function resolveSymbolicLink(filePath: string): string { if (!fs.lstatSync(filePath).isSymbolicLink()) { return filePath; @@ -56,7 +78,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 fetch(fileOrURL, { headers: parseHeaders(httpHeaders), }); diff --git a/test/check-windows-schema-paths.ts b/test/check-windows-schema-paths.ts new file mode 100644 index 000000000..ca6e0643d --- /dev/null +++ b/test/check-windows-schema-paths.ts @@ -0,0 +1,148 @@ +// Regression check 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 "fs"; +import * as os from "os"; +import * as path from "path"; + +import { + FetchingJSONSchemaStore, + InputData, + JSONSchemaInput, + quicktype, +} from "quicktype-core"; + +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"); +} + +async function runCase(c: SchemaPathCase): Promise { + 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)) { + return ` ${c.description}: generated output does not contain the type from the $ref'd schema:\n${output}`; + } + + return undefined; + } catch (e) { + return ` ${c.description}: quicktype failed: ${e}`; + } finally { + process.chdir(previousCwd); + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +export async function checkWindowsSchemaPaths(): Promise { + const failures: string[] = []; + for (const c of cases) { + const failure = await runCase(c); + if (failure !== undefined) { + failures.push(failure); + } + } + + if (failures.length > 0) { + console.error( + `error: schema inputs given as absolute paths must work (issue #2869): + +${failures.join("\n")} + +Windows absolute paths (drive-letter and UNC) must be converted to "file:" +URIs before urijs parses them — see normalizeURI and friends in +packages/quicktype-core/src/input/JSONSchemaInput.ts and the "file:" URI +handling in packages/quicktype-core/src/input/io/NodeIO.ts.`, + ); + process.exit(1); + } +} + +// Allow running the check standalone: +// NODE_PATH=`pwd`/node_modules npx ts-node --project test/tsconfig.json test/check-windows-schema-paths.ts +if (require.main === module) { + checkWindowsSchemaPaths().then(() => { + console.error( + "* Schema inputs given as Windows and POSIX absolute paths work", + ); + }); +} diff --git a/test/test.ts b/test/test.ts index 5bd0b245c..99210223a 100755 --- a/test/test.ts +++ b/test/test.ts @@ -7,6 +7,7 @@ import { type Fixture, allFixtures } from "./fixtures"; import { affectedFixtures, divideParallelJobs } from "./buildkite"; import { checkJavaEnumAcronymCasing } from "./check-java-acronym-names"; import { checkCoreHasNoNodePrefixedImports } from "./check-no-node-imports"; +import { checkWindowsSchemaPaths } from "./check-windows-schema-paths"; const exit = require("exit"); const CPUs = Number.parseInt(process.env.CPUs || "0", 10) || os.cpus().length; @@ -27,6 +28,11 @@ async function main(sources: string[]) { // can't catch this (mangled constants still compile and round-trip). await checkJavaEnumAcronymCasing(); + // Regression check for issue #2869: schema inputs given as Windows + // absolute paths must work. The fixture harness can't catch this + // because its inputs are always plain POSIX paths. + await checkWindowsSchemaPaths(); + let fixtures = affectedFixtures(); const fixturesFromCmdline = process.env.FIXTURE; if (fixturesFromCmdline) {