Skip to content

Commit

Permalink
fix #4731: prevent opening too large files
Browse files Browse the repository at this point in the history
Signed-off-by: Anton Kosyakov <anton.kosyakov@typefox.io>
  • Loading branch information
akosyakov committed Jul 28, 2020
1 parent 87fddbb commit 40e5c35
Show file tree
Hide file tree
Showing 4 changed files with 98 additions and 12 deletions.
25 changes: 17 additions & 8 deletions packages/filesystem/src/browser/file-resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<void>();
Expand Down Expand Up @@ -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,
Expand All @@ -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({
Expand Down Expand Up @@ -266,7 +275,7 @@ export class FileResourceResolver implements ResourceResolver {
protected async shouldOpenAsText(uri: URI, error: string): Promise<boolean> {
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'
});
Expand Down
39 changes: 35 additions & 4 deletions packages/filesystem/src/browser/file-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -565,10 +566,7 @@ export class FileService {
}

async read(resource: URI, options?: ReadTextFileOptions): Promise<TextFileContent> {
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);
Expand All @@ -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<ReadTextFileOptions['limits']> = 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<FileStatWithMetadata & { encoding: string }> {
const provider = this.throwIfFileSystemIsReadonly(await this.withWriteProvider(resource), resource);
try {
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions packages/filesystem/src/browser/filesystem-preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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.'
}
}
};
Expand All @@ -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');
Expand Down
33 changes: 33 additions & 0 deletions packages/filesystem/src/common/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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';
}
}

0 comments on commit 40e5c35

Please sign in to comment.