Skip to content

Commit

Permalink
Allow to save files that need user elevation (fixes #1614)
Browse files Browse the repository at this point in the history
  • Loading branch information
bpasero committed Dec 12, 2017
1 parent ceea046 commit 79cd5e8
Show file tree
Hide file tree
Showing 14 changed files with 195 additions and 22 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions src/typings/sudo-prompt.d.ts
Original file line number Diff line number Diff line change
@@ -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);
}
26 changes: 26 additions & 0 deletions src/vs/code/node/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down Expand Up @@ -55,6 +56,31 @@ export async function main(argv: string[]): TPromise<any> {
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 exists as file
) {
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, {
Expand Down
2 changes: 2 additions & 0 deletions src/vs/platform/environment/common/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<IEnvironmentService>('environmentService');
Expand All @@ -71,6 +72,7 @@ export interface IEnvironmentService {
args: ParsedArgs;

execPath: string;
cliPath: string;
appRoot: string;

userHome: string;
Expand Down
3 changes: 2 additions & 1 deletion src/vs/platform/environment/node/argv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
39 changes: 36 additions & 3 deletions src/vs/platform/environment/node/environmentService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -29,15 +30,44 @@ 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 {

// Windows
if (isWindows) {
if (isBuilt) {
return path.join(path.dirname(execPath), 'bin', `${product.applicationName}.cmd`);
}

return path.join(appRoot, 'scripts', 'code-cli.bat');
}

// Linux
if (isLinux) {
if (isBuilt) {
return path.join(path.dirname(execPath), 'bin', `${product.applicationName}`);
}

return path.join(appRoot, 'scripts', 'code-cli.sh');
}

// macOS
if (isBuilt) {
return path.join(appRoot, 'bin', 'code');
}

return path.join(appRoot, 'scripts', 'code-cli.sh');
}

export class EnvironmentService implements IEnvironmentService {
Expand All @@ -51,6 +81,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
Expand Down
7 changes: 7 additions & 0 deletions src/vs/platform/files/common/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,20 @@ export class SaveErrorHandler implements ISaveErrorHandler, IWorkbenchContributi
// Any other save error
else {
const isReadonly = (<FileOperationError>error).fileOperationResult === FileOperationResult.FILE_READ_ONLY;
const isPermissionDenied = (<FileOperationError>error).fileOperationResult === FileOperationResult.FILE_PERMISSION_DENIED;
const actions: Action[] = [];

// Save Elevated
if (isPermissionDenied) {
actions.push(new Action('workbench.files.action.saveElevated', nls.localize('saveElevated', "Retry as Admin..."), null, true, () => {
if (!model.isDisposed()) {
model.save({ writeElevated: true }).done(null, 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);
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -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
Expand Down
76 changes: 71 additions & 5 deletions src/vs/workbench/services/files/node/fileService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -54,6 +56,12 @@ export interface IFileServiceOptions {
disableWatcher?: boolean;
verboseLogging?: boolean;
useExperimentalFileWatcher?: boolean;
writeElevated?: (source: string, target: string) => TPromise<void>;
elevationSupport?: {
cliPath: string;
promptTitle: string;
promptIcnsPath?: string;
};
}

function etag(stat: fs.Stats): string;
Expand Down Expand Up @@ -351,9 +359,6 @@ export class FileService implements IFileService {
});
}

//#region data stream


private resolveFileData(resource: uri, options: IResolveContentOptions, token: CancellationToken): Thenable<IContentData> {

const chunkBuffer = BufferPool._64K.acquire();
Expand Down Expand Up @@ -484,9 +489,15 @@ export class FileService implements IFileService {
});
}

//#endregion

public updateContent(resource: uri, value: string, options: IUpdateContentOptions = Object.create(null)): TPromise<IFileStat> {
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<IFileStat> {
const absolutePath = this.toAbsolutePath(resource);

// 1.) check file
Expand Down Expand Up @@ -539,6 +550,15 @@ export class FileService implements IFileService {
});
});
});
}).then(null, error => {
if (error.code === 'EACCES' || error.code === 'EPERM') {
return TPromise.wrapError(new FileOperationError(
nls.localize('filePermission', "Permission denied writing to file ({0})", resource.toString(true)),
FileOperationResult.FILE_PERMISSION_DENIED
));
}

return TPromise.wrapError(error);
});
}

Expand All @@ -564,6 +584,51 @@ export class FileService implements IFileService {
});
}

private doUpdateContentElevated(resource: uri, value: string, options: IUpdateContentOptions = Object.create(null)): TPromise<IFileStat> {
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(() => {

// 3.) invoke our CLI as super user
return new TPromise<void>((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 (this.options.verboseLogging) {
this.options.errorLogger(`Unable to write to file '${resource.toString(true)}' as elevated user (${error})`);
}

if (!(error instanceof FileOperationError)) {
error = new FileOperationError(
nls.localize('filePermission', "Permission denied writing to file ({0})", resource.toString(true)),
FileOperationResult.FILE_PERMISSION_DENIED
);
}

return TPromise.wrapError(error);
});
}

public createFile(resource: uri, content: string = '', options: ICreateFileOptions = Object.create(null)): TPromise<IFileStat> {
const absolutePath = this.toAbsolutePath(resource);

Expand Down Expand Up @@ -865,6 +930,7 @@ export class FileService implements IFileService {

if (readonly) {
mode = mode | 128;

return pfs.chmod(absolutePath, mode).then(() => exists);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand Down
Loading

0 comments on commit 79cd5e8

Please sign in to comment.