Skip to content
Merged
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
7 changes: 4 additions & 3 deletions packages/quicktype-core/src/input/JSONSchemaInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
panic,
parseJSON,
} from "../support/Support";
import { fixWindowsPath } from "../support/WindowsPaths";
import {
type PrimitiveTypeKind,
type TransformedStringTypeKind,
Expand Down Expand Up @@ -143,15 +144,15 @@ 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()));
}

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

Expand Down Expand Up @@ -100,7 +101,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 globalThis.fetch(fileOrURL, {
headers: parseHeaders(httpHeaders),
});
Expand Down
44 changes: 44 additions & 0 deletions packages/quicktype-core/src/support/WindowsPaths.ts
Original file line number Diff line number Diff line change
@@ -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;
}
85 changes: 85 additions & 0 deletions test/unit/windows-paths.test.ts
Original file line number Diff line number Diff line change
@@ -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:///<drive>:/..." 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);
});
});
123 changes: 123 additions & 0 deletions test/unit/windows-schema-paths.test.ts
Original file line number Diff line number Diff line change
@@ -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<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");
}

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 });
}
});
}
});
Loading