Skip to content

Commit 4e9cd88

Browse files
author
Benjamin Pasero
committed
save as admin - handle case for readonly files
1 parent 8f18e87 commit 4e9cd88

8 files changed

Lines changed: 111 additions & 55 deletions

File tree

resources/linux/bin/code.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@
33
# Copyright (c) Microsoft Corporation. All rights reserved.
44
# Licensed under the MIT License. See License.txt in the project root for license information.
55

6-
# If root, ensure that --user-data-dir or --write-elevated-helper is specified
6+
# If root, ensure that --user-data-dir or --sudo-write is specified
77
if [ "$(id -u)" = "0" ]; then
88
for i in $@
99
do
10-
if [[ $i == --user-data-dir=* || $i == --write-elevated-helper ]]; then
10+
if [[ $i == --user-data-dir=* || $i == --sudo-write ]]; then
1111
CAN_LAUNCH_AS_ROOT=1
1212
fi
1313
done

src/vs/code/node/cli.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export async function main(argv: string[]): TPromise<any> {
5757
}
5858

5959
// Write Elevated
60-
else if (args['write-elevated-helper']) {
60+
else if (args['sudo-write']) {
6161
const source = args._[0];
6262
const target = args._[1];
6363

@@ -68,14 +68,31 @@ export async function main(argv: string[]): TPromise<any> {
6868
!fs.existsSync(source) || !fs.statSync(source).isFile() || // make sure source exists as file
6969
!fs.existsSync(target) || !fs.statSync(target).isFile() // make sure target exists as file
7070
) {
71-
return TPromise.wrapError(new Error('Using --write-elevated-helper with invalid arguments.'));
71+
return TPromise.wrapError(new Error('Using --sudo-write with invalid arguments.'));
7272
}
7373

74-
// Write source to target
7574
try {
75+
76+
// Check for readonly status and chmod if so if we are told so
77+
let targetMode: number;
78+
let restoreMode = false;
79+
if (!!args['sudo-chmod']) {
80+
targetMode = fs.statSync(target).mode;
81+
if (!(targetMode & 128) /* readonly */) {
82+
fs.chmodSync(target, targetMode | 128);
83+
restoreMode = true;
84+
}
85+
}
86+
87+
// Write source to target
7688
writeFileAndFlushSync(target, fs.readFileSync(source));
89+
90+
// Restore previous mode as needed
91+
if (restoreMode) {
92+
fs.chmodSync(target, targetMode);
93+
}
7794
} catch (error) {
78-
return TPromise.wrapError(new Error(`Using --write-elevated-helper resulted in an error: ${error}`));
95+
return TPromise.wrapError(new Error(`Using --sudo-write resulted in an error: ${error}`));
7996
}
8097

8198
return TPromise.as(null);

src/vs/platform/environment/common/environment.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ export interface ParsedArgs {
5252
'disable-updates'?: string;
5353
'disable-crash-reporter'?: string;
5454
'skip-add-to-recently-opened'?: boolean;
55-
'write-elevated-helper'?: boolean;
55+
'sudo-write'?: boolean;
56+
'sudo-chmod'?: boolean;
5657
}
5758

5859
export const IEnvironmentService = createDecorator<IEnvironmentService>('environmentService');

src/vs/platform/environment/node/argv.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ const options: minimist.Opts = {
5555
'disable-crash-reporter',
5656
'skip-add-to-recently-opened',
5757
'status',
58-
'write-elevated-helper'
58+
'sudo-write',
59+
'sudo-chmod'
5960
],
6061
alias: {
6162
add: 'a',

src/vs/platform/files/common/files.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -562,7 +562,7 @@ export interface IImportResult {
562562
}
563563

564564
export class FileOperationError extends Error {
565-
constructor(message: string, public fileOperationResult: FileOperationResult) {
565+
constructor(message: string, public fileOperationResult: FileOperationResult, public options?: IResolveContentOptions & IUpdateContentOptions & ICreateFileOptions) {
566566
super(message);
567567
}
568568
}

src/vs/workbench/parts/files/electron-browser/saveErrorHandler.ts

Lines changed: 42 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -101,10 +101,12 @@ export class SaveErrorHandler implements ISaveErrorHandler, IWorkbenchContributi
101101

102102
public onSaveError(error: any, model: ITextFileEditorModel): void {
103103
let message: IMessageWithAction | string;
104+
105+
const fileOperationError = error as FileOperationError;
104106
const resource = model.getResource();
105107

106108
// Dirty write prevention
107-
if ((<FileOperationError>error).fileOperationResult === FileOperationResult.FILE_MODIFIED_SINCE) {
109+
if (fileOperationError.fileOperationResult === FileOperationResult.FILE_MODIFIED_SINCE) {
108110

109111
// If the user tried to save from the opened conflict editor, show its message again
110112
// Otherwise show the message that will lead the user into the save conflict editor.
@@ -117,21 +119,48 @@ export class SaveErrorHandler implements ISaveErrorHandler, IWorkbenchContributi
117119

118120
// Any other save error
119121
else {
120-
const isReadonly = (<FileOperationError>error).fileOperationResult === FileOperationResult.FILE_READ_ONLY;
121-
const isPermissionDenied = (<FileOperationError>error).fileOperationResult === FileOperationResult.FILE_PERMISSION_DENIED;
122122
const actions: Action[] = [];
123123

124+
const isReadonly = fileOperationError.fileOperationResult === FileOperationResult.FILE_READ_ONLY;
125+
const triedToMakeWriteable = isReadonly && fileOperationError.options && fileOperationError.options.overwriteReadonly;
126+
const isPermissionDenied = fileOperationError.fileOperationResult === FileOperationResult.FILE_PERMISSION_DENIED;
127+
124128
// Save Elevated
125-
if (isPermissionDenied) {
126-
actions.push(new Action('workbench.files.action.saveElevated', nls.localize('saveElevated', "Retry as Admin..."), null, true, () => {
129+
if (isPermissionDenied || triedToMakeWriteable) {
130+
actions.push(new Action('workbench.files.action.saveElevated', triedToMakeWriteable ? nls.localize('overwriteElevated', "Overwrite as Admin...") : nls.localize('saveElevated', "Retry as Admin..."), null, true, () => {
131+
if (!model.isDisposed()) {
132+
model.save({
133+
writeElevated: true,
134+
overwriteReadonly: triedToMakeWriteable
135+
}).done(null, errors.onUnexpectedError);
136+
}
137+
138+
return TPromise.as(true);
139+
}));
140+
}
141+
142+
// Overwrite
143+
else if (isReadonly) {
144+
actions.push(new Action('workbench.files.action.overwrite', nls.localize('overwrite', "Overwrite"), null, true, () => {
127145
if (!model.isDisposed()) {
128-
model.save({ writeElevated: true }).done(null, errors.onUnexpectedError);
146+
model.save({ overwriteReadonly: true }).done(null, errors.onUnexpectedError);
129147
}
130148

131149
return TPromise.as(true);
132150
}));
133151
}
134152

153+
// Retry
154+
else {
155+
actions.push(new Action('workbench.files.action.retry', nls.localize('retry', "Retry"), null, true, () => {
156+
const saveFileAction = this.instantiationService.createInstance(SaveFileAction, SaveFileAction.ID, SaveFileAction.LABEL);
157+
saveFileAction.setResource(resource);
158+
saveFileAction.run().done(() => saveFileAction.dispose(), errors.onUnexpectedError);
159+
160+
return TPromise.as(true);
161+
}));
162+
}
163+
135164
// Save As
136165
actions.push(new Action('workbench.files.action.saveAs', SaveFileAsAction.LABEL, null, true, () => {
137166
const saveAsAction = this.instantiationService.createInstance(SaveFileAsAction, SaveFileAsAction.ID, SaveFileAsAction.LABEL);
@@ -150,31 +179,18 @@ export class SaveErrorHandler implements ISaveErrorHandler, IWorkbenchContributi
150179
return TPromise.as(true);
151180
}));
152181

153-
// Retry
154-
if (isReadonly) {
155-
actions.push(new Action('workbench.files.action.overwrite', nls.localize('overwrite', "Overwrite"), null, true, () => {
156-
if (!model.isDisposed()) {
157-
model.save({ overwriteReadonly: true }).done(null, errors.onUnexpectedError);
158-
}
159-
160-
return TPromise.as(true);
161-
}));
162-
} else if (!isPermissionDenied) {
163-
actions.push(new Action('workbench.files.action.retry', nls.localize('retry', "Retry"), null, true, () => {
164-
const saveFileAction = this.instantiationService.createInstance(SaveFileAction, SaveFileAction.ID, SaveFileAction.LABEL);
165-
saveFileAction.setResource(resource);
166-
saveFileAction.run().done(() => saveFileAction.dispose(), errors.onUnexpectedError);
167-
168-
return TPromise.as(true);
169-
}));
170-
}
171-
172182
// Cancel
173183
actions.push(CancelAction);
174184

175185
let errorMessage: string;
176186
if (isReadonly) {
177-
errorMessage = nls.localize('readonlySaveError', "Failed to save '{0}': File is write protected. Select 'Overwrite' to attempt to remove protection.", paths.basename(resource.fsPath));
187+
if (triedToMakeWriteable) {
188+
errorMessage = nls.localize('readonlySaveErrorAdmin', "Failed to save '{0}': File is write protected. Select 'Overwrite as Admin' to retry as administrator.", paths.basename(resource.fsPath));
189+
} else {
190+
errorMessage = nls.localize('readonlySaveError', "Failed to save '{0}': File is write protected. Select 'Overwrite' to attempt to remove protection.", paths.basename(resource.fsPath));
191+
}
192+
} else if (isPermissionDenied) {
193+
errorMessage = nls.localize('permissionDeniedSaveError', "Failed to save '{0}': Insufficient permissions. Select 'Retry as Admin' to retry as administrator.", paths.basename(resource.fsPath));
178194
} else {
179195
errorMessage = nls.localize('genericSaveError', "Failed to save '{0}': {1}", paths.basename(resource.fsPath), toErrorMessage(error, false));
180196
}

src/vs/workbench/services/files/electron-browser/remoteFileService.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,8 @@ export class RemoteFileService extends FileService {
249249
if (options.acceptTextOnly && detected.mimes.indexOf(MIME_BINARY) >= 0) {
250250
return TPromise.wrapError<IStreamContent>(new FileOperationError(
251251
localize('fileBinaryError', "File seems to be binary and cannot be opened as text"),
252-
FileOperationResult.FILE_IS_BINARY
252+
FileOperationResult.FILE_IS_BINARY,
253+
options
253254
));
254255
}
255256

@@ -324,7 +325,7 @@ export class RemoteFileService extends FileService {
324325

325326
return prepare.then(exists => {
326327
if (exists && options && !options.overwrite) {
327-
return TPromise.wrapError(new FileOperationError('EEXIST', FileOperationResult.FILE_MODIFIED_SINCE));
328+
return TPromise.wrapError(new FileOperationError('EEXIST', FileOperationResult.FILE_MODIFIED_SINCE, options));
328329
}
329330
return this._doUpdateContent(provider, resource, content || '', {});
330331
}).then(fileStat => {

src/vs/workbench/services/files/node/fileService.ts

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,8 @@ export class FileService implements IFileService {
262262
if (resource.scheme !== 'file' || !resource.fsPath) {
263263
return TPromise.wrapError<IStreamContent>(new FileOperationError(
264264
nls.localize('fileInvalidPath', "Invalid file resource ({0})", resource.toString(true)),
265-
FileOperationResult.FILE_INVALID_PATH
265+
FileOperationResult.FILE_INVALID_PATH,
266+
options
266267
));
267268
}
268269

@@ -298,23 +299,26 @@ export class FileService implements IFileService {
298299
if (stat.isDirectory) {
299300
return onStatError(new FileOperationError(
300301
nls.localize('fileIsDirectoryError', "File is directory"),
301-
FileOperationResult.FILE_IS_DIRECTORY
302+
FileOperationResult.FILE_IS_DIRECTORY,
303+
options
302304
));
303305
}
304306

305307
// Return early if file not modified since
306308
if (options && options.etag && options.etag === stat.etag) {
307309
return onStatError(new FileOperationError(
308310
nls.localize('fileNotModifiedError', "File not modified since"),
309-
FileOperationResult.FILE_NOT_MODIFIED_SINCE
311+
FileOperationResult.FILE_NOT_MODIFIED_SINCE,
312+
options
310313
));
311314
}
312315

313316
// Return early if file is too large to load
314317
if (typeof stat.size === 'number' && stat.size > MAX_FILE_SIZE) {
315318
return onStatError(new FileOperationError(
316319
nls.localize('fileTooLargeError', "File too large to open"),
317-
FileOperationResult.FILE_TOO_LARGE
320+
FileOperationResult.FILE_TOO_LARGE,
321+
options
318322
));
319323
}
320324

@@ -325,7 +329,8 @@ export class FileService implements IFileService {
325329
if (err.code === 'ENOENT') {
326330
return onStatError(new FileOperationError(
327331
nls.localize('fileNotFoundError', "File not found ({0})", resource.toString(true)),
328-
FileOperationResult.FILE_NOT_FOUND
332+
FileOperationResult.FILE_NOT_FOUND,
333+
options
329334
));
330335
}
331336

@@ -374,7 +379,8 @@ export class FileService implements IFileService {
374379
// Wrap file not found errors
375380
err = new FileOperationError(
376381
nls.localize('fileNotFoundError', "File not found ({0})", resource.toString(true)),
377-
FileOperationResult.FILE_NOT_FOUND
382+
FileOperationResult.FILE_NOT_FOUND,
383+
options
378384
);
379385
}
380386

@@ -391,7 +397,8 @@ export class FileService implements IFileService {
391397
// Wrap EISDIR errors (fs.open on a directory works, but you cannot read from it)
392398
err = new FileOperationError(
393399
nls.localize('fileIsDirectoryError', "File is directory"),
394-
FileOperationResult.FILE_IS_DIRECTORY
400+
FileOperationResult.FILE_IS_DIRECTORY,
401+
options
395402
);
396403
}
397404
if (decoder) {
@@ -442,7 +449,8 @@ export class FileService implements IFileService {
442449
// stop when reading too much
443450
finish(new FileOperationError(
444451
nls.localize('fileTooLargeError', "File too large to open"),
445-
FileOperationResult.FILE_TOO_LARGE
452+
FileOperationResult.FILE_TOO_LARGE,
453+
options
446454
));
447455
} else if (err) {
448456
// some error happened
@@ -464,7 +472,8 @@ export class FileService implements IFileService {
464472
// Return error early if client only accepts text and this is not text
465473
finish(new FileOperationError(
466474
nls.localize('fileBinaryError', "File seems to be binary and cannot be opened as text"),
467-
FileOperationResult.FILE_IS_BINARY
475+
FileOperationResult.FILE_IS_BINARY,
476+
options
468477
));
469478

470479
} else {
@@ -553,7 +562,8 @@ export class FileService implements IFileService {
553562
if (error.code === 'EACCES' || error.code === 'EPERM') {
554563
return TPromise.wrapError(new FileOperationError(
555564
nls.localize('filePermission', "Permission denied writing to file ({0})", resource.toString(true)),
556-
FileOperationResult.FILE_PERMISSION_DENIED
565+
FileOperationResult.FILE_PERMISSION_DENIED,
566+
options
557567
));
558568
}
559569

@@ -600,7 +610,14 @@ export class FileService implements IFileService {
600610
return (import('sudo-prompt')).then(sudoPrompt => {
601611
return new TPromise<void>((c, e) => {
602612
const promptOptions = { name: this.options.elevationSupport.promptTitle.replace('-', ''), icns: this.options.elevationSupport.promptIcnsPath };
603-
sudoPrompt.exec(`"${this.options.elevationSupport.cliPath}" --write-elevated-helper "${tmpPath}" "${absolutePath}"`, promptOptions, (error: string, stdout: string, stderr: string) => {
613+
614+
const sudoCommand: string[] = [`"${this.options.elevationSupport.cliPath}"`];
615+
if (options.overwriteReadonly) {
616+
sudoCommand.push('--sudo-chmod');
617+
}
618+
sudoCommand.push('--sudo-write', `"${tmpPath}"`, `"${absolutePath}"`);
619+
620+
sudoPrompt.exec(sudoCommand.join(' '), promptOptions, (error: string, stdout: string, stderr: string) => {
604621
if (error || stderr) {
605622
e(error || stderr);
606623
} else {
@@ -622,7 +639,8 @@ export class FileService implements IFileService {
622639
if (!(error instanceof FileOperationError)) {
623640
error = new FileOperationError(
624641
nls.localize('filePermission', "Permission denied writing to file ({0})", resource.toString(true)),
625-
FileOperationResult.FILE_PERMISSION_DENIED
642+
FileOperationResult.FILE_PERMISSION_DENIED,
643+
options
626644
);
627645
}
628646

@@ -645,7 +663,8 @@ export class FileService implements IFileService {
645663
if (exists && !options.overwrite) {
646664
return TPromise.wrapError<IFileStat>(new FileOperationError(
647665
nls.localize('fileExists', "File to create already exists ({0})", resource.toString(true)),
648-
FileOperationResult.FILE_MODIFIED_SINCE
666+
FileOperationResult.FILE_MODIFIED_SINCE,
667+
options
649668
));
650669
}
651670

@@ -914,14 +933,14 @@ export class FileService implements IFileService {
914933

915934
// Find out if content length has changed
916935
if (options.etag !== etag(stat.size, options.mtime)) {
917-
return TPromise.wrapError<boolean>(new FileOperationError(nls.localize('fileModifiedError', "File Modified Since"), FileOperationResult.FILE_MODIFIED_SINCE));
936+
return TPromise.wrapError<boolean>(new FileOperationError(nls.localize('fileModifiedError', "File Modified Since"), FileOperationResult.FILE_MODIFIED_SINCE, options));
918937
}
919938
}
920939

921940
// Throw if file is readonly and we are not instructed to overwrite
922941
if (!(stat.mode & 128) /* readonly */) {
923942
if (!options.overwriteReadonly) {
924-
return this.readOnlyError<boolean>();
943+
return this.readOnlyError<boolean>(options);
925944
}
926945

927946
// Try to change mode to writeable
@@ -932,7 +951,7 @@ export class FileService implements IFileService {
932951
// Make sure to check the mode again, it could have failed
933952
return pfs.stat(absolutePath).then(stat => {
934953
if (!(stat.mode & 128) /* readonly */) {
935-
return this.readOnlyError<boolean>();
954+
return this.readOnlyError<boolean>(options);
936955
}
937956

938957
return exists;
@@ -948,10 +967,11 @@ export class FileService implements IFileService {
948967
});
949968
}
950969

951-
private readOnlyError<T>(): TPromise<T> {
970+
private readOnlyError<T>(options: IUpdateContentOptions): TPromise<T> {
952971
return TPromise.wrapError<T>(new FileOperationError(
953972
nls.localize('fileReadOnlyError', "File is Read Only"),
954-
FileOperationResult.FILE_READ_ONLY
973+
FileOperationResult.FILE_READ_ONLY,
974+
options
955975
));
956976
}
957977

0 commit comments

Comments
 (0)