Fix web TS Server trying to read files outside of project root (#173591)

There's a bug on the TS Server side that causes it to traverse out of the project root when checking for directories like `node_modules`. On web this ends up being super slow because it goes to the network

This fix blocks those reads
This commit is contained in:
Matt Bierner
2023-02-06 11:17:21 -08:00
committed by GitHub
parent 10ba138a7b
commit eec5c907ea
@@ -14,6 +14,9 @@ import { URI } from 'vscode-uri';
const watchFiles: Map<string, { path: string; callback: ts.FileWatcherCallback; pollingInterval?: number; options?: ts.WatchOptions }> = new Map();
const watchDirectories: Map<string, { path: string; callback: ts.DirectoryWatcherCallback; recursive?: boolean; options?: ts.WatchOptions }> = new Map();
let session: WorkerSession | undefined;
const projectRootPaths = new Map</* original path*/ string, /* parsed URI */ URI>();
// END GLOBALS
// BEGIN misc internals
const indent: (str: string) => string = (ts as any).server.indent;
@@ -188,7 +191,7 @@ function createServerHost(extensionUri: URI, logger: ts.server.Logger, apiClient
const contents = fs.readFile(toResource(path)).slice();
return textDecoder.decode(contents);
} catch (error) {
logNormal('Error fs.readFile', { path, error });
logNormal('Error fs.readFile', { path, error: error + '' });
return undefined;
}
},
@@ -202,7 +205,7 @@ function createServerHost(extensionUri: URI, logger: ts.server.Logger, apiClient
try {
return fs.stat(toResource(path)).size;
} catch (error) {
logNormal('Error fs.getFileSize', { path, error });
logNormal('Error fs.getFileSize', { path, error: error + '' });
return 0;
}
},
@@ -220,7 +223,7 @@ function createServerHost(extensionUri: URI, logger: ts.server.Logger, apiClient
try {
fs.writeFile(toResource(path), textEncoder.encode(data));
} catch (error) {
logNormal('Error fs.writeFile', { path, error });
logNormal('Error fs.writeFile', { path, error: error + '' });
}
},
resolvePath(path: string): string {
@@ -244,7 +247,7 @@ function createServerHost(extensionUri: URI, logger: ts.server.Logger, apiClient
try {
return fs.stat(toResource(path)).type === FileType.File;
} catch (error) {
logNormal('Error fs.fileExists', { path, error });
logNormal('Error fs.fileExists', { path, error: error + '' });
return false;
}
},
@@ -258,7 +261,7 @@ function createServerHost(extensionUri: URI, logger: ts.server.Logger, apiClient
try {
return fs.stat(toResource(path)).type === FileType.Directory;
} catch (error) {
logNormal('Error fs.directoryExists', { path, error });
logNormal('Error fs.directoryExists', { path, error: error + '' });
return false;
}
},
@@ -272,7 +275,7 @@ function createServerHost(extensionUri: URI, logger: ts.server.Logger, apiClient
try {
fs.createDirectory(toResource(path));
} catch (error) {
logNormal('Error fs.createDirectory', { path, error });
logNormal('Error fs.createDirectory', { path, error: error + '' });
}
},
getExecutingFilePath(): string {
@@ -297,7 +300,7 @@ function createServerHost(extensionUri: URI, logger: ts.server.Logger, apiClient
try {
return new Date(fs.stat(toResource(path)).mtime);
} catch (error) {
logNormal('Error fs.getModifiedTime', { path, error });
logNormal('Error fs.getModifiedTime', { path, error: error + '' });
return undefined;
}
},
@@ -311,7 +314,7 @@ function createServerHost(extensionUri: URI, logger: ts.server.Logger, apiClient
try {
fs.delete(toResource(path));
} catch (error) {
logNormal('Error fs.deleteFile', { path, error });
logNormal('Error fs.deleteFile', { path, error: error + '' });
}
},
createHash: generateDjb2Hash,
@@ -386,7 +389,7 @@ function createServerHost(extensionUri: URI, logger: ts.server.Logger, apiClient
/**
* Copied from toResource in typescriptServiceClient.ts
*/
function toResource(filepath: string) {
function toResource(filepath: string): URI {
if (filepath.startsWith('/lib.') && filepath.endsWith('.d.ts')) {
return URI.from({
scheme: extensionUri.scheme,
@@ -394,14 +397,48 @@ function createServerHost(extensionUri: URI, logger: ts.server.Logger, apiClient
path: extensionUri.path + '/dist/browser/typescript/' + filepath.slice(1)
});
}
const parts = filepath.match(/^\/([^\/]+)\/([^\/]*)(?:\/(.+))?$/);
if (!parts) {
throw new Error('complex regex failed to match ' + filepath);
const uri = filePathToResourceUri(filepath);
if (!uri) {
throw new Error(`Could not parse path ${filepath}`);
}
return URI.parse(parts[1] + '://' + (parts[2] === 'ts-nul-authority' ? '' : parts[2]) + (parts[3] ? '/' + parts[3] : ''));
// Check if TS is trying to read a file outside of the project root.
// We allow reading files on unknown scheme as these may be loose files opened by the user.
// However we block reading files on schemes that are on a known file system with an unknown root
let allowRead: 'implicit' | 'block' | 'allow' = 'implicit';
for (const projectRoot of projectRootPaths.values()) {
if (uri.scheme === projectRoot.scheme) {
if (uri.toString().startsWith(projectRoot.toString())) {
allowRead = 'allow';
break;
}
// Tentatively block the read but a future loop may allow it
allowRead = 'block';
}
}
if (allowRead === 'block') {
throw new Error(`Could not read file outside of project root ${filepath}`);
}
return uri;
}
}
function filePathToResourceUri(filepath: string): URI | undefined {
const parts = filepath.match(/^\/([^\/]+)\/([^\/]*)(?:\/(.+))?$/);
if (!parts) {
return undefined;
}
const scheme = parts[1];
const authority = parts[2] === 'ts-nul-authority' ? '' : parts[2];
const path = parts[3];
return URI.from({ scheme, authority, path: (path ? '/' + path : path) });
}
class WasmCancellationToken implements ts.server.ServerCancellationToken {
shouldCancel: (() => boolean) | undefined;
currentRequestId: number | undefined = undefined;
@@ -435,12 +472,14 @@ interface StartSessionOptions {
}
class WorkerSession extends ts.server.Session<{}> {
wasmCancellationToken: WasmCancellationToken;
listener: (message: any) => void;
readonly wasmCancellationToken: WasmCancellationToken;
readonly listener: (message: any) => void;
constructor(
host: ts.server.ServerHost,
options: StartSessionOptions,
public port: MessagePort,
public readonly port: MessagePort,
logger: ts.server.Logger,
hrtime: ts.server.SessionOptions['hrtime']
) {
@@ -450,12 +489,13 @@ class WorkerSession extends ts.server.Session<{}> {
cancellationToken,
...options,
typingsInstaller: ts.server.nullTypingsInstaller, // TODO: Someday!
byteLength: () => { throw new Error('Not implemented'); }, // Formats the message text in send of Session which is overriden in this class so not needed
byteLength: () => { throw new Error('Not implemented'); }, // Formats the message text in send of Session which is overridden in this class so not needed
hrtime,
logger,
canUseEvents: true,
});
this.wasmCancellationToken = cancellationToken;
this.listener = (message: any) => {
// TEMP fix since Cancellation.retrieveCheck is not correct
function retrieveCheck2(data: any) {
@@ -472,9 +512,27 @@ class WorkerSession extends ts.server.Session<{}> {
if (shouldCancel) {
this.wasmCancellationToken.shouldCancel = shouldCancel;
}
try {
if (message.data.command === 'updateOpen') {
const args = message.data.arguments as ts.server.protocol.UpdateOpenRequestArgs;
for (const open of args.openFiles ?? []) {
if (open.projectRootPath) {
const uri = filePathToResourceUri(open.projectRootPath);
if (uri) {
projectRootPaths.set(open.projectRootPath, uri);
}
}
}
}
} catch {
// Noop
}
this.onMessage(message.data);
};
}
public override send(msg: ts.server.protocol.Message) {
if (msg.type === 'event' && !this.canUseEvents) {
if (this.logger.hasLevel(ts.server.LogLevel.verbose)) {
@@ -487,18 +545,22 @@ class WorkerSession extends ts.server.Session<{}> {
}
this.port.postMessage(msg);
}
protected override parseMessage(message: {}): ts.server.protocol.Request {
return message as ts.server.protocol.Request;
}
protected override toStringMessage(message: {}) {
return JSON.stringify(message, undefined, 2);
}
override exit() {
this.logger.info('Exiting...');
this.port.removeEventListener('message', this.listener);
this.projectService.closeLog();
close();
}
listen() {
this.logger.info(`webServer.ts: tsserver starting to listen for messages on 'message'...`);
this.port.onmessage = this.listener;