Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow elevation through non-linux commands #1881

Merged
merged 10 commits into from
Aug 7, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -153,14 +153,20 @@ We cannot verify our .NET file host at this time. Please try again later or inst
}
const installerResult : string = await this.executeInstall(installerFile);

if(this.cleanupInstallFiles)
{
this.file.wipeDirectory(path.dirname(installerFile), this.acquisitionContext.eventStream);
}
return this.handleStatus(installerResult, installerFile);
}

private async handleStatus(installerResult : string, installerFile : string, allowRetry = true) : Promise<string>
{
const validInstallerStatusCodes = ['0', '1641', '3010']; // Ok, Pending Reboot, + Reboot Starting Now
const noPermissionStatusCodes = ['1', '5', '1260', '2147942405'];

if(validInstallerStatusCodes.includes(installerResult))
{
if(this.cleanupInstallFiles)
{
this.file.wipeDirectory(path.dirname(installerFile), this.acquisitionContext.eventStream);
}
return '0'; // These statuses are a success, we don't want to throw.
}
else if(installerResult === '1602')
Expand All @@ -171,6 +177,11 @@ We cannot verify our .NET file host at this time. Please try again later or inst
this.acquisitionContext.eventStream.post(err);
throw err.error;
}
else if(noPermissionStatusCodes.includes(installerResult) && allowRetry)
{
const retryWithElevationResult = await this.executeInstall(installerFile, true);
return this.handleStatus(retryWithElevationResult, installerFile, false);
}
else
{
return installerResult;
Expand Down Expand Up @@ -312,7 +323,7 @@ If you were waiting for the install to succeed, please extend the timeout settin
* @param installerPath The path to the installer file to run.
* @returns The exit result from running the global install.
*/
public async executeInstall(installerPath : string) : Promise<string>
public async executeInstall(installerPath : string, elevateVsCode = false) : Promise<string>
{
if(os.platform() === 'darwin')
{
Expand Down Expand Up @@ -364,7 +375,7 @@ Please correct your PATH variable or make sure the 'open' utility is installed s
this.acquisitionContext.eventStream.post(new NetInstallerBeginExecutionEvent(`The Windows .NET Installer has been launched.`));
try
{
const commandResult = await this.commandRunner.execute(CommandExecutor.makeCommand(command, commandOptions), {timeout : this.acquisitionContext.timeoutSeconds * 1000});
const commandResult = await this.commandRunner.execute(CommandExecutor.makeCommand(command, commandOptions, elevateVsCode), {timeout : this.acquisitionContext.timeoutSeconds * 1000});
this.handleTimeout(commandResult);
this.acquisitionContext.eventStream.post(new NetInstallerEndExecutionEvent(`The Windows .NET Installer has closed.`));
return commandResult.status;
Expand Down
84 changes: 54 additions & 30 deletions vscode-dotnet-runtime-library/src/Utils/CommandExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import {
TimeoutSudoProcessSpawnerError,
EventBasedError
} from '../EventStream/EventStreamEvents';
import {exec} from '@vscode/sudo-prompt';
import {exec as execElevated} from '@vscode/sudo-prompt';
import * as lockfile from 'proper-lockfile';
import { CommandExecutorCommand } from './CommandExecutorCommand';
import { getInstallFromContext } from './InstallIdUtilities';
Expand Down Expand Up @@ -125,13 +125,11 @@ Please install the .NET SDK manually by following https://learn.microsoft.com/en
this.context?.eventStream.post(new CommandExecutionUserAskDialogueEvent(`Prompting user for command ${fullCommandString} under sudo.`));

// The '.' character is not allowed for sudo-prompt so we use 'NET'
nagilson marked this conversation as resolved.
Show resolved Hide resolved
let sanitizedCallerName = this.context?.acquisitionContext?.requestingExtensionId?.replace(/[^0-9a-z]/gi, ''); // Remove non-alphanumerics per OS requirements
sanitizedCallerName = sanitizedCallerName?.substring(0, 69); // 70 Characters is the maximum limit we can use for the prompt.
const options = { name: `${sanitizedCallerName ?? 'NET Install Tool'}` };
const options = { name: `${this.getSanitizedCallerName()}` };

fs.chmodSync(shellScriptPath, 0o500);
const timeoutSeconds = Math.max(100, this.context.timeoutSeconds);
exec((`"${shellScriptPath}" "${this.sudoProcessCommunicationDir}" "${timeoutSeconds}" ${this.validSudoCommands?.join(' ')} &`), options, (error?: any, stdout?: any, stderr?: any) =>
execElevated((`"${shellScriptPath}" "${this.sudoProcessCommunicationDir}" "${timeoutSeconds}" ${this.validSudoCommands?.join(' ')} &`), options, (error?: any, stdout?: any, stderr?: any) =>
{
this.context?.eventStream.post(new CommandExecutionStdOut(`The process spawn: ${fullCommandString} encountered stdout, continuing
${stdout}`));
Expand All @@ -144,24 +142,7 @@ ${stderr}`));
this.context?.eventStream.post(new CommandExecutionUserCompletedDialogueEvent(`The process spawn: ${fullCommandString} failed to run under sudo.`));
if(terminalFailure)
{
if(error.code === 126)
{
const cancelledErr = new CommandExecutionUserRejectedPasswordRequest(new EventCancellationError('CommandExecutionUserRejectedPasswordRequest',
`Cancelling .NET Install, as command ${fullCommandString} failed.
The user refused the password prompt.`),
getInstallFromContext(this.context));
this.context?.eventStream.post(cancelledErr);
return Promise.reject(cancelledErr.error);
}
else if(error.code === 111777)
{
const securityErr = new CommandExecutionUnknownCommandExecutionAttempt(new EventCancellationError('CommandExecutionUnknownCommandExecutionAttempt',
`Cancelling .NET Install, as command ${fullCommandString} is UNKNOWN.
Please report this at https://github.com/dotnet/vscode-dotnet-runtime/issues.`),
getInstallFromContext(this.context));
this.context?.eventStream.post(securityErr);
return Promise.reject(securityErr.error);
}
this.parseVSCodeSudoExecError(error, fullCommandString);
return Promise.reject(error);
}
else
Expand Down Expand Up @@ -362,7 +343,7 @@ ${(commandOutputJson as CommandExecutorResult).stderr}.`),
*/
public async execute(command : CommandExecutorCommand, options : any | null = null, terminalFailure = true) : Promise<CommandExecutorResult>
{
const fullCommandStringForTelemetryOnly = `${command.commandRoot} ${command.commandParts.join(' ')}`;
const fullCommandString = `${command.commandRoot} ${command.commandParts.join(' ')}`;
if(options && !options?.cwd)
{
options.cwd = path.resolve(__dirname);
Expand All @@ -376,23 +357,37 @@ ${(commandOutputJson as CommandExecutorResult).stderr}.`),
options = {cwd : path.resolve(__dirname), shell: true};
}

if(command.runUnderSudo)
if(command.runUnderSudo && os.platform() === 'linux')
{
return this.ExecSudoAsync(command, terminalFailure);
}
else
{
this.context?.eventStream.post(new CommandExecutionEvent(`Executing command ${fullCommandStringForTelemetryOnly}
this.context?.eventStream.post(new CommandExecutionEvent(`Executing command ${fullCommandString}
with options ${JSON.stringify(options)}.`));
const commandResult = proc.spawnSync(command.commandRoot, command.commandParts, options);

let commandResult;

if(command.runUnderSudo)
{
execElevated(fullCommandString, options, (error?: any, execStdout?: any, execStderr?: any) =>
{
if(terminalFailure)
{
return Promise.resolve(this.parseVSCodeSudoExecError(error, fullCommandString));
}
return Promise.resolve({ status: error ? error.code : '0', stderr: execStderr, stdout: execStdout} as CommandExecutorResult);
});
}

commandResult = proc.spawnSync(command.commandRoot, command.commandParts, options);

if(os.platform() === 'win32')
{
proc.spawn('taskkill', ['/pid', commandResult.pid.toString(), '/f', '/t']);
}


this.logCommandResult(commandResult, fullCommandStringForTelemetryOnly);
this.logCommandResult(commandResult, fullCommandString);

const statusCode : string = (() =>
{
Expand All @@ -410,7 +405,7 @@ with options ${JSON.stringify(options)}.`));
}
else
{
this.context?.eventStream.post(new CommandExecutionNoStatusCodeWarning(`The command ${fullCommandStringForTelemetryOnly} with
this.context?.eventStream.post(new CommandExecutionNoStatusCodeWarning(`The command ${fullCommandString} with
result: ${commandResult.toString()} had no status or signal.`));
return '000751'; // Error code 000751 : The command did not report an exit code upon completion. This is never expected
}
Expand All @@ -436,6 +431,28 @@ ${commandResult.stdout}`));
${commandResult.stderr}`));
}

private parseVSCodeSudoExecError(error : any, fullCommandString : string)
{
if(error.code === 126)
{
const cancelledErr = new CommandExecutionUserRejectedPasswordRequest(new EventCancellationError('CommandExecutionUserRejectedPasswordRequest',
`Cancelling .NET Install, as command ${fullCommandString} failed.
The user refused the password prompt.`),
getInstallFromContext(this.context));
this.context?.eventStream.post(cancelledErr);
return Promise.reject(cancelledErr.error);
}
else if(error.code === 111777)
{
const securityErr = new CommandExecutionUnknownCommandExecutionAttempt(new EventCancellationError('CommandExecutionUnknownCommandExecutionAttempt',
`Cancelling .NET Install, as command ${fullCommandString} is UNKNOWN.
Please report this at https://github.com/dotnet/vscode-dotnet-runtime/issues.`),
getInstallFromContext(this.context));
this.context?.eventStream.post(securityErr);
return Promise.reject(securityErr.error);
}
}

/**
*
* @param commandRoots The first word of each command to try
Expand Down Expand Up @@ -533,6 +550,13 @@ ${commandResult.stderr}`));
}
}

private getSanitizedCallerName() : string
{
let sanitizedCallerName = this.context?.acquisitionContext?.requestingExtensionId?.replace(/[^0-9a-z]/gi, ''); // Remove non-alphanumerics per OS requirements
sanitizedCallerName = sanitizedCallerName?.substring(0, 69); // 70 Characters is the maximum limit we can use for the prompt.
return sanitizedCallerName ?? 'NET Install Tool';
}

protected getLinuxPathCommand(pathAddition: string): string | undefined
{
const profileFile = os.platform() === 'darwin' ? path.join(os.homedir(), '.zshrc') : path.join(os.homedir(), '.profile');
Expand Down