Allow to save files that need user elevation (fixes #1614) (#40107)

This commit is contained in:
Benjamin Pasero 2017-12-12 16:03:25 +01:00 committed by GitHub
parent a4e30c1223
commit 0ef13b6d54
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 195 additions and 22 deletions

View file

@ -39,6 +39,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",

9
src/typings/sudo-prompt.d.ts vendored Normal file
View file

@ -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);
}

View file

@ -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<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, {

View file

@ -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');
@ -71,6 +72,7 @@ export interface IEnvironmentService {
args: ParsedArgs;
execPath: string;
cliPath: string;
appRoot: string;
userHome: string;

View file

@ -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',

View file

@ -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,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 {
@ -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

View file

@ -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
}

View file

@ -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);
@ -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);

View file

@ -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

View file

@ -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<void>;
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<IContentData> {
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<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
@ -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);
});
}
@ -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);
@ -865,6 +930,7 @@ export class FileService implements IFileService {
if (readonly) {
mode = mode | 128;
return pfs.chmod(absolutePath, mode).then(() => exists);
}

View file

@ -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());

View file

@ -526,7 +526,7 @@ export abstract class TextFileService implements ITextFileService {
return this.getFileModels(arg1).filter(model => model.isDirty());
}
public saveAs(resource: URI, target?: URI): TPromise<URI> {
public saveAs(resource: URI, target?: URI, options?: ISaveOptions): TPromise<URI> {
// Get to target resource
if (!target) {
@ -547,14 +547,14 @@ export abstract class TextFileService implements ITextFileService {
// Just save if target is same as models own resource
if (resource.toString() === target.toString()) {
return this.save(resource).then(() => resource);
return this.save(resource, options).then(() => resource);
}
// Do it
return this.doSaveAs(resource, target);
return this.doSaveAs(resource, target, options);
}
private doSaveAs(resource: URI, target?: URI): TPromise<URI> {
private doSaveAs(resource: URI, target?: URI, options?: ISaveOptions): TPromise<URI> {
// Retrieve text model from provided resource if any
let modelPromise: TPromise<ITextFileEditorModel | UntitledEditorModel> = TPromise.as(null);
@ -568,7 +568,7 @@ export abstract class TextFileService implements ITextFileService {
// We have a model: Use it (can be null e.g. if this file is binary and not a text file or was never opened before)
if (model) {
return this.doSaveTextFileAs(model, resource, target);
return this.doSaveTextFileAs(model, resource, target, options);
}
// Otherwise we can only copy
@ -584,7 +584,7 @@ export abstract class TextFileService implements ITextFileService {
});
}
private doSaveTextFileAs(sourceModel: ITextFileEditorModel | UntitledEditorModel, resource: URI, target: URI): TPromise<void> {
private doSaveTextFileAs(sourceModel: ITextFileEditorModel | UntitledEditorModel, resource: URI, target: URI, options?: ISaveOptions): TPromise<void> {
let targetModelResolver: TPromise<ITextFileEditorModel>;
// Prefer an existing model if it is already loaded for the given target resource
@ -607,12 +607,12 @@ export abstract class TextFileService implements ITextFileService {
targetModel.textEditorModel.setValue(sourceModel.getValue());
// save model
return targetModel.save();
return targetModel.save(options);
}, error => {
// binary model: delete the file and run the operation again
if ((<FileOperationError>error).fileOperationResult === FileOperationResult.FILE_IS_BINARY || (<FileOperationError>error).fileOperationResult === FileOperationResult.FILE_TOO_LARGE) {
return this.fileService.del(target).then(() => this.doSaveTextFileAs(sourceModel, resource, target));
return this.fileService.del(target).then(() => this.doSaveTextFileAs(sourceModel, resource, target, options));
}
return TPromise.wrapError(error);

View file

@ -179,6 +179,7 @@ export interface ISaveOptions {
overwriteReadonly?: boolean;
overwriteEncoding?: boolean;
skipSaveParticipants?: boolean;
writeElevated?: boolean;
}
export interface ITextFileEditorModel extends ITextEditorModel, IEncodingSupport {
@ -246,17 +247,20 @@ export interface ITextFileService extends IDisposable {
* Saves the resource.
*
* @param resource the resource to save
* @param options optional save options
* @return true if the resource was saved.
*/
save(resource: URI, options?: ISaveOptions): TPromise<boolean>;
/**
* Saves the provided resource asking the user for a file name.
* Saves the provided resource asking the user for a file name or using the provided one.
*
* @param resource the resource to save as.
* @param targetResource the optional target to save to.
* @param options optional save options
* @return true if the file was saved.
*/
saveAs(resource: URI, targetResource?: URI): TPromise<URI>;
saveAs(resource: URI, targetResource?: URI, options?: ISaveOptions): TPromise<URI>;
/**
* Saves the set of resources and returns a promise with the operation result.

View file

@ -5209,6 +5209,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"