diff --git a/package.json b/package.json index 8535979bdf1b8..4ff2bacad2c91 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "nsfw": "1.0.16", "semver": "4.3.6", "spdlog": "0.3.7", + "sudo-prompt": "^8.0.0", "v8-inspect-profiler": "^0.0.7", "vscode-chokidar": "1.6.2", "vscode-debugprotocol": "1.25.0", diff --git a/src/typings/sudo-prompt.d.ts b/src/typings/sudo-prompt.d.ts new file mode 100644 index 0000000000000..4a20422905160 --- /dev/null +++ b/src/typings/sudo-prompt.d.ts @@ -0,0 +1,9 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'sudo-prompt' { + + export function exec(cmd: string, options: { name?: string, icns?: string }, callback: (error: string, stdout: string, stderr: string) => void); +} \ No newline at end of file diff --git a/src/vs/code/node/cli.ts b/src/vs/code/node/cli.ts index fe645ff4e0dbb..c7fd3ac170787 100644 --- a/src/vs/code/node/cli.ts +++ b/src/vs/code/node/cli.ts @@ -17,6 +17,7 @@ import { whenDeleted } from 'vs/base/node/pfs'; import { findFreePort } from 'vs/base/node/ports'; import { resolveTerminalEncoding } from 'vs/base/node/encoding'; import * as iconv from 'iconv-lite'; +import { writeFileAndFlushSync } from 'vs/base/node/extfs'; function shouldSpawnCliProcess(argv: ParsedArgs): boolean { return !!argv['install-source'] @@ -55,6 +56,31 @@ export async function main(argv: string[]): TPromise { return mainCli.then(cli => cli.main(args)); } + // Write Elevated + else if (args['write-elevated-helper']) { + const source = args._[0]; + const target = args._[1]; + + // Validate + if ( + !source || !target || source === target || // make sure source and target are provided and are not the same + !paths.isAbsolute(source) || !paths.isAbsolute(target) || // make sure both source and target are absolute paths + !fs.existsSync(source) || !fs.statSync(source).isFile() || // make sure source exists as file + (fs.existsSync(target) && !fs.statSync(target).isFile()) // make sure target is a file if existing + ) { + return TPromise.wrapError(new Error('Using --write-elevated-helper with invalid arguments.')); + } + + // Write source to target + try { + writeFileAndFlushSync(target, fs.readFileSync(source)); + } catch (error) { + return TPromise.wrapError(new Error(`Using --write-elevated-helper resulted in an error: ${error}`)); + } + + return TPromise.as(null); + } + // Just Code else { const env = assign({}, process.env, { diff --git a/src/vs/platform/environment/common/environment.ts b/src/vs/platform/environment/common/environment.ts index 932af88fa1e38..d3fc28397ffb8 100644 --- a/src/vs/platform/environment/common/environment.ts +++ b/src/vs/platform/environment/common/environment.ts @@ -52,6 +52,7 @@ export interface ParsedArgs { 'disable-updates'?: string; 'disable-crash-reporter'?: string; 'skip-add-to-recently-opened'?: boolean; + 'write-elevated-helper'?: boolean; } export const IEnvironmentService = createDecorator('environmentService'); @@ -71,6 +72,7 @@ export interface IEnvironmentService { args: ParsedArgs; execPath: string; + cliPath: string; appRoot: string; userHome: string; diff --git a/src/vs/platform/environment/node/argv.ts b/src/vs/platform/environment/node/argv.ts index 5de73213eff9d..d546e1511ecb7 100644 --- a/src/vs/platform/environment/node/argv.ts +++ b/src/vs/platform/environment/node/argv.ts @@ -54,7 +54,8 @@ const options: minimist.Opts = { 'disable-updates', 'disable-crash-reporter', 'skip-add-to-recently-opened', - 'status' + 'status', + 'write-elevated-helper' ], alias: { add: 'a', diff --git a/src/vs/platform/environment/node/environmentService.ts b/src/vs/platform/environment/node/environmentService.ts index 4e7728abac0b3..7d6665dfe43bb 100644 --- a/src/vs/platform/environment/node/environmentService.ts +++ b/src/vs/platform/environment/node/environmentService.ts @@ -14,6 +14,7 @@ import pkg from 'vs/platform/node/package'; import product from 'vs/platform/node/product'; import { LogLevel } from 'vs/platform/log/common/log'; import { toLocalISOString } from 'vs/base/common/date'; +import { isWindows, isLinux } from 'vs/base/common/platform'; // Read this before there's any chance it is overwritten // Related to https://github.com/Microsoft/vscode/issues/30624 @@ -29,15 +30,40 @@ function getNixIPCHandle(userDataPath: string, type: string): string { function getWin32IPCHandle(userDataPath: string, type: string): string { const scope = crypto.createHash('md5').update(userDataPath).digest('hex'); + return `\\\\.\\pipe\\${scope}-${pkg.version}-${type}-sock`; } function getIPCHandle(userDataPath: string, type: string): string { - if (process.platform === 'win32') { + if (isWindows) { return getWin32IPCHandle(userDataPath, type); - } else { - return getNixIPCHandle(userDataPath, type); } + + return getNixIPCHandle(userDataPath, type); +} + +function getCLIPath(execPath: string, appRoot: string, isBuilt: boolean): string { + if (isWindows) { + if (isBuilt) { + return path.join(path.dirname(execPath), 'bin', `${product.applicationName}.cmd`); + } + + return path.join(appRoot, 'scripts', 'code-cli.bat'); + } + + if (isLinux) { + if (isBuilt) { + return path.join(path.dirname(execPath), 'bin', `${product.applicationName}`); + } + + return path.join(appRoot, 'scripts', 'code-cli.sh'); + } + + if (isBuilt) { + return path.join(appRoot, 'bin', 'code'); + } + + return path.join(appRoot, 'scripts', 'code-cli.sh'); } export class EnvironmentService implements IEnvironmentService { @@ -51,6 +77,9 @@ export class EnvironmentService implements IEnvironmentService { get execPath(): string { return this._execPath; } + @memoize + get cliPath(): string { return getCLIPath(this.execPath, this.appRoot, this.isBuilt); } + readonly logsPath: string; @memoize diff --git a/src/vs/platform/files/common/files.ts b/src/vs/platform/files/common/files.ts index 262916172b60b..1f2c999da9f3f 100644 --- a/src/vs/platform/files/common/files.ts +++ b/src/vs/platform/files/common/files.ts @@ -525,6 +525,12 @@ export interface IUpdateContentOptions { */ overwriteReadonly?: boolean; + /** + * Wether to write to the file as elevated (admin) user. When setting this option a prompt will + * ask the user to authenticate as super user. + */ + writeElevated?: boolean; + /** * The last known modification time of the file. This can be used to prevent dirty writes. */ @@ -569,6 +575,7 @@ export enum FileOperationResult { FILE_MODIFIED_SINCE, FILE_MOVE_CONFLICT, FILE_READ_ONLY, + FILE_PERMISSION_DENIED, FILE_TOO_LARGE, FILE_INVALID_PATH } diff --git a/src/vs/workbench/parts/files/electron-browser/fileActions.ts b/src/vs/workbench/parts/files/electron-browser/fileActions.ts index 2b3598a96bab1..70e21864cbf25 100644 --- a/src/vs/workbench/parts/files/electron-browser/fileActions.ts +++ b/src/vs/workbench/parts/files/electron-browser/fileActions.ts @@ -1359,6 +1359,8 @@ export abstract class BaseSaveOneFileAction extends BaseSaveFileAction { public abstract isSaveAs(): boolean; + public abstract writeElevated(): boolean; + public setResource(resource: URI): void { this.resource = resource; } @@ -1396,7 +1398,7 @@ export abstract class BaseSaveOneFileAction extends BaseSaveFileAction { // Special case: an untitled file with associated path gets saved directly unless "saveAs" is true let savePromise: TPromise; if (!this.isSaveAs() && source.scheme === 'untitled' && this.untitledEditorService.hasAssociatedFilePath(source)) { - savePromise = this.textFileService.save(source).then((result) => { + savePromise = this.textFileService.save(source, { writeElevated: this.writeElevated() }).then((result) => { if (result) { return URI.file(source.fsPath); } @@ -1440,7 +1442,10 @@ export abstract class BaseSaveOneFileAction extends BaseSaveFileAction { } // Just save - return this.textFileService.save(source, { force: true /* force a change to the file to trigger external watchers if any */ }); + return this.textFileService.save(source, { + writeElevated: this.writeElevated(), + force: true /* force a change to the file to trigger external watchers if any */ + }); } return TPromise.as(false); @@ -1455,6 +1460,10 @@ export class SaveFileAction extends BaseSaveOneFileAction { public isSaveAs(): boolean { return false; } + + public writeElevated(): boolean { + return false; + } } export class SaveFileAsAction extends BaseSaveOneFileAction { @@ -1465,6 +1474,24 @@ export class SaveFileAsAction extends BaseSaveOneFileAction { public isSaveAs(): boolean { return true; } + + public writeElevated(): boolean { + return false; + } +} + +export class SaveFileElevated extends BaseSaveOneFileAction { + + public static readonly ID = 'workbench.action.files.saveElevated'; + public static readonly LABEL = nls.localize('saveElevatedWindows', "Retry as Admin..."); + + public writeElevated(): boolean { + return true; + } + + public isSaveAs(): boolean { + return false; + } } export abstract class BaseSaveAllAction extends BaseSaveFileAction { diff --git a/src/vs/workbench/parts/files/electron-browser/saveErrorHandler.ts b/src/vs/workbench/parts/files/electron-browser/saveErrorHandler.ts index 586fd2cbc0509..2562ed893f340 100644 --- a/src/vs/workbench/parts/files/electron-browser/saveErrorHandler.ts +++ b/src/vs/workbench/parts/files/electron-browser/saveErrorHandler.ts @@ -11,7 +11,7 @@ import { toErrorMessage } from 'vs/base/common/errorMessage'; import paths = require('vs/base/common/paths'); import { Action } from 'vs/base/common/actions'; import URI from 'vs/base/common/uri'; -import { SaveFileAsAction, RevertFileAction, SaveFileAction } from 'vs/workbench/parts/files/electron-browser/fileActions'; +import { SaveFileAsAction, RevertFileAction, SaveFileAction, SaveFileElevated } from 'vs/workbench/parts/files/electron-browser/fileActions'; import { FileOperationError, FileOperationResult } from 'vs/platform/files/common/files'; import { IEnvironmentService } from 'vs/platform/environment/common/environment'; import { IWorkbenchEditorService } from 'vs/workbench/services/editor/common/editorService'; @@ -118,8 +118,20 @@ export class SaveErrorHandler implements ISaveErrorHandler, IWorkbenchContributi // Any other save error else { const isReadonly = (error).fileOperationResult === FileOperationResult.FILE_READ_ONLY; + const isPermissionDenied = (error).fileOperationResult === FileOperationResult.FILE_PERMISSION_DENIED; const actions: Action[] = []; + // Save Elevated + if (isPermissionDenied) { + actions.push(new Action('workbench.files.action.saveElevated', SaveFileElevated.LABEL, null, true, () => { + const saveElevatedAction = this.instantiationService.createInstance(SaveFileElevated, SaveFileElevated.ID, SaveFileElevated.LABEL); + saveElevatedAction.setResource(resource); + saveElevatedAction.run().done(() => saveElevatedAction.dispose(), errors.onUnexpectedError); + + return TPromise.as(true); + })); + } + // Save As actions.push(new Action('workbench.files.action.saveAs', SaveFileAsAction.LABEL, null, true, () => { const saveAsAction = this.instantiationService.createInstance(SaveFileAsAction, SaveFileAsAction.ID, SaveFileAsAction.LABEL); @@ -147,7 +159,7 @@ export class SaveErrorHandler implements ISaveErrorHandler, IWorkbenchContributi return TPromise.as(true); })); - } else { + } else if (!isPermissionDenied) { actions.push(new Action('workbench.files.action.retry', nls.localize('retry', "Retry"), null, true, () => { const saveFileAction = this.instantiationService.createInstance(SaveFileAction, SaveFileAction.ID, SaveFileAction.LABEL); saveFileAction.setResource(resource); diff --git a/src/vs/workbench/services/files/electron-browser/fileService.ts b/src/vs/workbench/services/files/electron-browser/fileService.ts index 18987ccf7e092..bcd10448f92bb 100644 --- a/src/vs/workbench/services/files/electron-browser/fileService.ts +++ b/src/vs/workbench/services/files/electron-browser/fileService.ts @@ -24,6 +24,8 @@ import Event, { Emitter } from 'vs/base/common/event'; import { shell } from 'electron'; import { ITextResourceConfigurationService } from 'vs/editor/common/services/resourceConfiguration'; +import { isMacintosh } from 'vs/base/common/platform'; +import product from 'vs/platform/node/product'; export class FileService implements IFileService { @@ -70,7 +72,12 @@ export class FileService implements IFileService { encodingOverride: this.getEncodingOverrides(), watcherIgnoredPatterns, verboseLogging: environmentService.verbose, - useExperimentalFileWatcher: configuration.files.useExperimentalFileWatcher + useExperimentalFileWatcher: configuration.files.useExperimentalFileWatcher, + elevationSupport: { + cliPath: this.environmentService.cliPath, + promptTitle: this.environmentService.appNameLong, + promptIcnsPath: (isMacintosh && this.environmentService.isBuilt) ? paths.join(paths.dirname(this.environmentService.appRoot), `${product.nameShort}.icns`) : void 0 + } }; // create service diff --git a/src/vs/workbench/services/files/node/fileService.ts b/src/vs/workbench/services/files/node/fileService.ts index c1c668826b1ea..f2735ec579761 100644 --- a/src/vs/workbench/services/files/node/fileService.ts +++ b/src/vs/workbench/services/files/node/fileService.ts @@ -10,6 +10,7 @@ import fs = require('fs'); import os = require('os'); import crypto = require('crypto'); import assert = require('assert'); +import sudoPrompt = require('sudo-prompt'); import { isParent, FileOperation, FileOperationEvent, IContent, IFileService, IResolveFileOptions, IResolveFileResult, IResolveContentOptions, IFileStat, IStreamContent, FileOperationError, FileOperationResult, IUpdateContentOptions, FileChangeType, IImportResult, FileChangesEvent, ICreateFileOptions, IContentData } from 'vs/platform/files/common/files'; import { MAX_FILE_SIZE } from 'vs/platform/files/node/files'; @@ -40,6 +41,7 @@ import { IConfigurationService } from 'vs/platform/configuration/common/configur import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; import { ILifecycleService, LifecyclePhase } from 'vs/platform/lifecycle/common/lifecycle'; import { getBaseLabel } from 'vs/base/common/labels'; +import { assign } from 'vs/base/common/objects'; export interface IEncodingOverride { resource: uri; @@ -54,6 +56,12 @@ export interface IFileServiceOptions { disableWatcher?: boolean; verboseLogging?: boolean; useExperimentalFileWatcher?: boolean; + writeElevated?: (source: string, target: string) => TPromise; + elevationSupport?: { + cliPath: string; + promptTitle: string; + promptIcnsPath?: string; + }; } function etag(stat: fs.Stats): string; @@ -351,9 +359,6 @@ export class FileService implements IFileService { }); } - //#region data stream - - private resolveFileData(resource: uri, options: IResolveContentOptions, token: CancellationToken): Thenable { const chunkBuffer = BufferPool._64K.acquire(); @@ -484,9 +489,15 @@ export class FileService implements IFileService { }); } - //#endregion - public updateContent(resource: uri, value: string, options: IUpdateContentOptions = Object.create(null)): TPromise { + if (this.options.elevationSupport && options.writeElevated) { + return this.doUpdateContentElevated(resource, value, options); + } + + return this.doUpdateContent(resource, value, options); + } + + private doUpdateContent(resource: uri, value: string, options: IUpdateContentOptions = Object.create(null)): TPromise { const absolutePath = this.toAbsolutePath(resource); // 1.) check file @@ -539,6 +550,15 @@ export class FileService implements IFileService { }); }); }); + }).then(null, error => { + if (error.code === 'EACCES') { + return TPromise.wrapError(new FileOperationError( + nls.localize('filePermission', "Permission denied to write to file ({0})", resource.toString(true)), + FileOperationResult.FILE_PERMISSION_DENIED + )); + } + + return TPromise.wrapError(error); }); } @@ -564,6 +584,51 @@ export class FileService implements IFileService { }); } + private doUpdateContentElevated(resource: uri, value: string, options: IUpdateContentOptions = Object.create(null)): TPromise { + const absolutePath = this.toAbsolutePath(resource); + + // 1.) check file + return this.checkFile(absolutePath, options).then(exists => { + const writeOptions: IUpdateContentOptions = assign(Object.create(null), options); + writeOptions.writeElevated = false; + writeOptions.encoding = this.getEncoding(resource, options.encoding); + + // 2.) write to a temporary file to be able to copy over later + const tmpPath = paths.join(this.tmpPath, `code-elevated-${Math.random().toString(36).replace(/[^a-z]+/g, '').substr(0, 6)}`); + return this.updateContent(uri.file(tmpPath), value, writeOptions).then(stat => { + + // 3.) invoke our CLI as super user + return new TPromise((c, e) => { + const promptOptions = { name: this.options.elevationSupport.promptTitle.replace('-', ''), icns: this.options.elevationSupport.promptIcnsPath }; + sudoPrompt.exec(`${this.options.elevationSupport.cliPath} --write-elevated-helper ${tmpPath} ${absolutePath}`, promptOptions, (error: string, stdout: string, stderr: string) => { + if (error || stderr) { + e(error || stderr); + } else { + c(void 0); + } + }); + }).then(() => { + + // 3.) resolve again + return this.resolve(resource); + }); + }); + }).then(null, error => { + if (error instanceof FileOperationError) { + return TPromise.wrapError(error); // do not overwrite well known operation errors + } + + if (this.options.verboseLogging) { + this.options.errorLogger(error); + } + + return TPromise.wrapError(new FileOperationError( + nls.localize('filePermission', "Permission denied to write to file ({0})", resource.toString(true)), + FileOperationResult.FILE_PERMISSION_DENIED + )); + }); + } + public createFile(resource: uri, content: string = '', options: ICreateFileOptions = Object.create(null)): TPromise { const absolutePath = this.toAbsolutePath(resource); diff --git a/src/vs/workbench/services/textfile/common/textFileEditorModel.ts b/src/vs/workbench/services/textfile/common/textFileEditorModel.ts index 4905dff62b3a2..edeb287757fb4 100644 --- a/src/vs/workbench/services/textfile/common/textFileEditorModel.ts +++ b/src/vs/workbench/services/textfile/common/textFileEditorModel.ts @@ -712,7 +712,8 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil overwriteEncoding: options.overwriteEncoding, mtime: this.lastResolvedDiskStat.mtime, encoding: this.getEncoding(), - etag: this.lastResolvedDiskStat.etag + etag: this.lastResolvedDiskStat.etag, + writeElevated: options.writeElevated }).then(stat => { diag(`doSave(${versionId}) - after updateContent()`, this.resource, new Date()); diff --git a/src/vs/workbench/services/textfile/common/textfiles.ts b/src/vs/workbench/services/textfile/common/textfiles.ts index 2b3a6b0a8a908..1f13cb9f1ec0c 100644 --- a/src/vs/workbench/services/textfile/common/textfiles.ts +++ b/src/vs/workbench/services/textfile/common/textfiles.ts @@ -178,6 +178,7 @@ export interface ISaveOptions { overwriteReadonly?: boolean; overwriteEncoding?: boolean; skipSaveParticipants?: boolean; + writeElevated?: boolean; } export interface ITextFileEditorModel extends ITextEditorModel, IEncodingSupport { diff --git a/yarn.lock b/yarn.lock index f95110eef0888..f0669aeefa65b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5205,6 +5205,10 @@ strip-json-comments@~2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" +sudo-prompt@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/sudo-prompt/-/sudo-prompt-8.0.0.tgz#a7b4a1ca6cbcca0e705b90a89dfc81d11034cba9" + sumchecker@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/sumchecker/-/sumchecker-2.0.2.tgz#0f42c10e5d05da5d42eea3e56c3399a37d6c5b3e"