diff --git a/packages/filesystem/src/browser/file-resource.ts b/packages/filesystem/src/browser/file-resource.ts index 845b817fab951..274267dfaea88 100644 --- a/packages/filesystem/src/browser/file-resource.ts +++ b/packages/filesystem/src/browser/file-resource.ts @@ -19,10 +19,11 @@ import { Resource, ResourceVersion, ResourceResolver, ResourceError, ResourceSav import { DisposableCollection } from '@theia/core/lib/common/disposable'; import { Emitter, Event } from '@theia/core/lib/common/event'; import URI from '@theia/core/lib/common/uri'; -import { FileOperation, FileOperationError, FileOperationResult, ETAG_DISABLED, FileSystemProviderCapabilities } from '../common/files'; +import { FileOperation, FileOperationError, FileOperationResult, ETAG_DISABLED, FileSystemProviderCapabilities, FileReadStreamOptions, BinarySize } from '../common/files'; import { FileService, TextFileOperationError, TextFileOperationResult } from './file-service'; import { ConfirmDialog } from '@theia/core/lib/browser/dialogs'; import { LabelProvider } from '@theia/core/lib/browser/label-provider'; +import { GENERAL_MAX_FILE_SIZE_MB } from './filesystem-preferences'; export interface FileResourceVersion extends ResourceVersion { readonly encoding: string; @@ -43,6 +44,7 @@ export interface FileResourceOptions { export class FileResource implements Resource { protected acceptTextOnly = true; + protected limits: FileReadStreamOptions['limits']; protected readonly toDispose = new DisposableCollection(); protected readonly onDidChangeContentsEmitter = new Emitter(); @@ -95,7 +97,8 @@ export class FileResource implements Resource { const stat = await this.fileService.read(this.uri, { encoding, etag: ETAG_DISABLED, - acceptTextOnly: this.acceptTextOnly + acceptTextOnly: this.acceptTextOnly, + limits: this.limits }); this._version = { encoding: stat.encoding, @@ -105,14 +108,20 @@ export class FileResource implements Resource { return stat.value; } catch (e) { if (e instanceof TextFileOperationError && e.textFileOperationResult === TextFileOperationResult.FILE_IS_BINARY) { - if (await this.shouldOpenAsText(e.message)) { + if (await this.shouldOpenAsText('The file is either binary or uses an unsupported text encoding.')) { this.acceptTextOnly = false; return this.readContents(options); - } else { - throw e; } - } - if (e instanceof FileOperationError && e.fileOperationResult === FileOperationResult.FILE_NOT_FOUND) { + } else if (e instanceof FileOperationError && e.fileOperationResult === FileOperationResult.FILE_TOO_LARGE) { + const stat = await this.fileService.resolve(this.uri, { resolveMetadata: true }); + const maxFileSize = GENERAL_MAX_FILE_SIZE_MB * 1024 * 1024; + if (this.limits?.size !== maxFileSize && await this.shouldOpenAsText(`The file is too large (${BinarySize.formatSize(stat.size)}).`)) { + this.limits = { + size: maxFileSize + }; + return this.readContents(options); + } + } else if (e instanceof FileOperationError && e.fileOperationResult === FileOperationResult.FILE_NOT_FOUND) { this._version = undefined; const { message, stack } = e; throw ResourceError.NotFound({ @@ -266,7 +275,7 @@ export class FileResourceResolver implements ResourceResolver { protected async shouldOpenAsText(uri: URI, error: string): Promise { const dialog = new ConfirmDialog({ title: error, - msg: `Do you want to open '${this.labelProvider.getLongName(uri)}' anyway?`, + msg: `Opening it might take some time and might make the IDE unresponsive. Do you want to open '${this.labelProvider.getLongName(uri)}' anyway?`, ok: 'Yes', cancel: 'No' }); diff --git a/packages/filesystem/src/browser/file-service.ts b/packages/filesystem/src/browser/file-service.ts index 7d928162a9ea9..294ac4b084c9a 100644 --- a/packages/filesystem/src/browser/file-service.ts +++ b/packages/filesystem/src/browser/file-service.ts @@ -63,6 +63,7 @@ import type { TextDocumentContentChangeEvent } from 'vscode-languageserver-proto import { EncodingRegistry } from '@theia/core/lib/browser/encoding-registry'; import { UTF8, UTF8_with_bom } from '@theia/core/lib/common/encodings'; import { EncodingService, ResourceEncoding } from '@theia/core/lib/common/encoding-service'; +import { Mutable } from '@theia/core/lib/common/types'; export interface FileOperationParticipant { @@ -565,10 +566,7 @@ export class FileService { } async read(resource: URI, options?: ReadTextFileOptions): Promise { - options = { - ...options, - autoGuessEncoding: typeof options?.autoGuessEncoding === 'boolean' ? options.autoGuessEncoding : this.preferences['files.autoGuessEncoding'] - }; + options = this.resolveReadOptions(options); const content = await this.readFile(resource, options); // TODO stream const detected = await this.encodingService.detectEncoding(content.value, options.autoGuessEncoding); @@ -580,6 +578,18 @@ export class FileService { return { ...content, encoding, value }; } + protected resolveReadOptions(options?: ReadTextFileOptions): ReadTextFileOptions { + options = { + ...options, + autoGuessEncoding: typeof options?.autoGuessEncoding === 'boolean' ? options.autoGuessEncoding : this.preferences['files.autoGuessEncoding'] + }; + const limits: Mutable = options.limits = options.limits || {}; + if (typeof limits.size !== 'number') { + limits.size = this.preferences['files.maxFileSizeMB'] * 1024 * 1024; + } + return options; + } + async update(resource: URI, changes: TextDocumentContentChangeEvent[], options: UpdateTextFileOptions): Promise { const provider = this.throwIfFileSystemIsReadonly(await this.withWriteProvider(resource), resource); try { @@ -828,9 +838,30 @@ export class FileService { throw new FileOperationError('File not modified since', FileOperationResult.FILE_NOT_MODIFIED_SINCE, options); } + // Throw if file is too large to load + this.validateReadFileLimits(resource, stat.size, options); + return stat; } + private validateReadFileLimits(resource: URI, size: number, options?: ReadFileOptions): void { + if (options?.limits) { + let tooLargeErrorResult: FileOperationResult | undefined = undefined; + + if (typeof options.limits.memory === 'number' && size > options.limits.memory) { + tooLargeErrorResult = FileOperationResult.FILE_EXCEEDS_MEMORY_LIMIT; + } + + if (typeof options.limits.size === 'number' && size > options.limits.size) { + tooLargeErrorResult = FileOperationResult.FILE_TOO_LARGE; + } + + if (typeof tooLargeErrorResult === 'number') { + throw new FileOperationError(`Unable to read file '${this.resourceForError(resource)}' that is too large to open`, tooLargeErrorResult); + } + } + } + // #endregion // #region Move/Copy/Delete/Create Folder diff --git a/packages/filesystem/src/browser/filesystem-preferences.ts b/packages/filesystem/src/browser/filesystem-preferences.ts index 57044a67fd0c9..5c8e872b6dc14 100644 --- a/packages/filesystem/src/browser/filesystem-preferences.ts +++ b/packages/filesystem/src/browser/filesystem-preferences.ts @@ -23,6 +23,13 @@ import { PreferenceContribution } from '@theia/core/lib/browser/preferences'; import { SUPPORTED_ENCODINGS } from '@theia/core/lib/browser/supported-encodings'; +import { environment } from '@theia/application-package/lib/environment'; + +// See https://github.com/Microsoft/vscode/issues/30180 +export const WIN32_MAX_FILE_SIZE_MB = 300; // 300 MB +export const GENERAL_MAX_FILE_SIZE_MB = 16 * 1024; // 16 GB + +export const MAX_FILE_SIZE_MB = environment.electron.is() ? process.arch === 'ia32' ? WIN32_MAX_FILE_SIZE_MB : GENERAL_MAX_FILE_SIZE_MB : 32; export const filesystemPreferenceSchema: PreferenceSchema = { 'type': 'object', @@ -66,6 +73,11 @@ These have precedence over the default associations of the languages installed.' type: 'number', default: 5000, markdownDescription: 'Timeout in milliseconds after which file participants for create, rename, and delete are cancelled. Use `0` to disable participants.' + }, + 'files.maxFileSizeMB': { + type: 'number', + default: MAX_FILE_SIZE_MB, + markdownDescription: 'Controls the max file size in MB which is possible to open.' } } }; @@ -78,6 +90,7 @@ export interface FileSystemConfiguration { 'files.encoding': string; 'files.autoGuessEncoding': boolean; 'files.participants.timeout': number; + 'files.maxFileSizeMB': number; } export const FileSystemPreferences = Symbol('FileSystemPreferences'); diff --git a/packages/filesystem/src/common/files.ts b/packages/filesystem/src/common/files.ts index 193c2c66526cf..b521ca006fa61 100644 --- a/packages/filesystem/src/common/files.ts +++ b/packages/filesystem/src/common/files.ts @@ -443,6 +443,14 @@ export interface FileReadStreamOptions { * will be read. */ readonly length?: number; + + /** + * If provided, the size of the file will be checked against the limits. + */ + limits?: { + readonly size?: number; + readonly memory?: number; + }; } export interface FileUpdateOptions { @@ -695,3 +703,28 @@ export function etag(stat: { mtime: number | undefined, size: number | undefined return stat.mtime.toString(29) + stat.size.toString(31); } +/** + * Helper to format a raw byte size into a human readable label. + */ +export class BinarySize { + static readonly KB = 1024; + static readonly MB = BinarySize.KB * BinarySize.KB; + static readonly GB = BinarySize.MB * BinarySize.KB; + static readonly TB = BinarySize.GB * BinarySize.KB; + + static formatSize(size: number): string { + if (size < BinarySize.KB) { + return size + 'B'; + } + if (size < BinarySize.MB) { + return (size / BinarySize.KB).toFixed(2) + 'KB'; + } + if (size < BinarySize.GB) { + return (size / BinarySize.MB).toFixed(2) + 'MB'; + } + if (size < BinarySize.TB) { + return (size / BinarySize.GB).toFixed(2) + 'GB'; + } + return (size / BinarySize.TB).toFixed(2) + 'TB'; + } +}