Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions packages/quicktype-core/src/input/JSONSchemaInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,22 +136,41 @@ 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 {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Put that in a shared utility file and add comprehensive unit tests.

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
// open the URL as a file, escaped character will thwart us. I think the
// 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()));
}

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

Expand Down Expand Up @@ -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;
Expand Down
26 changes: 25 additions & 1 deletion packages/quicktype-core/src/input/io/NodeIO.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Put that in a utility file and add comprehensive unit tests.

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;
Expand All @@ -56,7 +78,9 @@ export async function readableFromFileOrURL(
httpHeaders?: string[],
): Promise<Readable> {
try {
if (isURL(fileOrURL)) {
if (fileOrURL.startsWith("file://")) {
fileOrURL = filePathFromFileURI(fileOrURL);
} else if (isURL(fileOrURL)) {
const response = await fetch(fileOrURL, {
headers: parseHeaders(httpHeaders),
});
Expand Down
148 changes: 148 additions & 0 deletions test/check-windows-schema-paths.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
// The same setup the CLI uses for `-s schema <path>`.
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<string | undefined> {
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<void> {
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",
);
});
}
6 changes: 6 additions & 0 deletions test/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand Down
Loading