diff --git a/extensions/typescript-language-features/web/webServer.ts b/extensions/typescript-language-features/web/webServer.ts index 73aac96fd52..a1a7e95f4a2 100644 --- a/extensions/typescript-language-features/web/webServer.ts +++ b/extensions/typescript-language-features/web/webServer.ts @@ -14,6 +14,9 @@ import { URI } from 'vscode-uri'; const watchFiles: Map = new Map(); const watchDirectories: Map = new Map(); let session: WorkerSession | undefined; + +const projectRootPaths = new Map(); + // 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;