patch: refactor permission code

This commit is contained in:
Edwin Joassart 2025-07-16 11:27:06 +02:00
parent 391164bf15
commit 4c2489d00f
No known key found for this signature in database
GPG Key ID: 6337EBE5AC716051
9 changed files with 28511 additions and 28576 deletions

View File

@ -98,7 +98,8 @@ async function connectToChildProcess(
): Promise<ChildApi | { failed: boolean }> { ): Promise<ChildApi | { failed: boolean }> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// TODO: default to IPC connections https://github.com/websockets/ws/blob/master/doc/ws.md#ipc-connections // TODO: default to IPC connections https://github.com/websockets/ws/blob/master/doc/ws.md#ipc-connections
// TOOD: use the path as cheap authentication // TODO: use the path as cheap authentication
console.log(etcherServerId); console.log(etcherServerId);
const url = `ws://${etcherServerAddress}:${etcherServerPort}`; const url = `ws://${etcherServerAddress}:${etcherServerPort}`;
@ -196,9 +197,9 @@ async function spawnChildAndConnect({
`etcher-${Math.random().toString(36).substring(7)}`; `etcher-${Math.random().toString(36).substring(7)}`;
console.log( console.log(
`Spawning ${ `Starting ${
withPrivileges ? 'priviledged' : 'unpriviledged' withPrivileges ? 'priviledged' : 'unpriviledged'
} sidecar on port ${etcherServerPort}`, } flasher sidecar on port ${etcherServerPort}`,
); );
// spawn the child process, which will act as the ws server // spawn the child process, which will act as the ws server
@ -212,11 +213,11 @@ async function spawnChildAndConnect({
etcherServerPort, etcherServerPort,
); );
if (result.cancelled) { if (result.cancelled) {
throw new Error('Spwaning the child process was cancelled'); throw new Error('Starting flasher sidecar process was cancelled');
} }
} catch (error) { } catch (error) {
console.error('Error spawning child process', error); console.error('Error starting flasher sidecar process', error);
throw new Error('Error spawning the child process'); throw new Error('Error starting flasher sidecar process');
} }
} }
@ -232,7 +233,7 @@ async function spawnChildAndConnect({
if (failed) { if (failed) {
retry++; retry++;
console.log( console.log(
`Retrying to connect to child process in ${connectionRetryDelay}... ${retry} / ${connectionRetryAttempts}`, `Connection to sidecar flasher process attempt ${retry} / ${connectionRetryAttempts} failed; retrying in ${connectionRetryDelay}ms...`,
); );
await new Promise((resolve) => await new Promise((resolve) =>
setTimeout(resolve, connectionRetryDelay), setTimeout(resolve, connectionRetryDelay),
@ -241,10 +242,11 @@ async function spawnChildAndConnect({
} }
return { failed, emit, registerHandler }; return { failed, emit, registerHandler };
} }
throw new Error('Connection to etcher-util timed out'); // TODO: raised an error to the user if we reach this point
throw new Error('Connection to sidecar flasher process timed out');
} catch (error) { } catch (error) {
console.error('Error connecting to child process', error); console.error('Error connecting to sidecar flasher process process', error);
throw new Error('Connection to etcher-util failed'); throw new Error('Connection to sidecar flasher process failed');
} }
} }

View File

@ -34,8 +34,6 @@ export function fromFlashState({
status: string; status: string;
position?: string; position?: string;
} { } {
console.log(i18next.t('progress.starting'));
if (type === undefined) { if (type === undefined) {
return { status: i18next.t('progress.starting') }; return { status: i18next.t('progress.starting') };
} else if (type === 'decompressing') { } else if (type === 'decompressing') {

View File

@ -14,16 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
/**
* TODO:
* This is convoluted and needlessly complex. It should be simplified and modernized.
* The environment variable setting and escaping should be greatly simplified by letting {linux|catalina}-sudo handle that.
* We shouldn't need to write a script to a file and then execute it. We should be able to forwatd the command to the sudo code directly.
*/
import { spawn, exec } from 'child_process'; import { spawn, exec } from 'child_process';
import { withTmpFile } from 'etcher-sdk/build/tmp';
import { promises as fs } from 'fs';
import { promisify } from 'util'; import { promisify } from 'util';
import * as _ from 'lodash'; import * as _ from 'lodash';
import * as os from 'os'; import * as os from 'os';
@ -66,74 +57,30 @@ export function isElevatedUnixSync(): boolean {
return process.geteuid!() === UNIX_SUPERUSER_USER_ID; return process.geteuid!() === UNIX_SUPERUSER_USER_ID;
} }
function escapeSh(value: any): string {
// Make sure it's a string
// Replace ' -> '\'' (closing quote, escaped quote, opening quote)
// Surround with quotes
return `'${String(value).replace(/'/g, "'\\''")}'`;
}
function escapeParamCmd(value: any): string {
// Make sure it's a string
// Escape " -> \"
// Surround with double quotes
return `"${String(value).replace(/"/g, '\\"')}"`;
}
function setEnvVarSh(value: any, name: string): string {
return `export ${name}=${escapeSh(value)}`;
}
function setEnvVarCmd(value: any, name: string): string {
return `set "${name}=${String(value)}"`;
}
// Exported for tests
export function createLaunchScript(
command: string,
argv: string[],
environment: _.Dictionary<string | undefined>,
): string {
const isWindows = os.platform() === 'win32';
const lines = [];
if (isWindows) {
// Switch to utf8
lines.push('chcp 65001');
}
const [setEnvVarFn, escapeFn] = isWindows
? [setEnvVarCmd, escapeParamCmd]
: [setEnvVarSh, escapeSh];
lines.push(..._.map(environment, setEnvVarFn));
lines.push([command, ...argv].map(escapeFn).join(' '));
return lines.join(os.EOL);
}
async function elevateScriptWindows( async function elevateScriptWindows(
path: string, command: string[],
name: string,
env: any, env: any,
): Promise<{ cancelled: false }> { ): Promise<{ cancelled: false }> {
// '&' needs to be escaped here (but not when written to a .cmd file) // '&' needs to be escaped here (but not when written to a .cmd file)
const cmd = ['cmd', '/c', escapeParamCmd(path).replace(/&/g, '^&')].join(' '); await winSudo(command, env);
await winSudo(cmd, name, env);
return { cancelled: false }; return { cancelled: false };
} }
async function elevateScriptUnix( async function elevateScriptUnix(
path: string, command: string[],
name: string, name: string,
env: any,
): Promise<{ cancelled: boolean }> { ): Promise<{ cancelled: boolean }> {
const cmd = ['bash', escapeSh(path)].join(' '); await linuxSudo(command, { name }, env);
await linuxSudo(cmd, { name });
return { cancelled: false }; return { cancelled: false };
} }
async function elevateScriptCatalina( async function elevateScriptCatalina(
path: string, command: string[],
env: any,
): Promise<{ cancelled: boolean }> { ): Promise<{ cancelled: boolean }> {
const cmd = ['bash', escapeSh(path)].join(' ');
try { try {
const { cancelled } = await darwinSudo(cmd); const { cancelled } = await darwinSudo(command, env);
return { cancelled }; return { cancelled };
} catch (error: any) { } catch (error: any) {
throw errors.createError({ title: error.stderr }); throw errors.createError({ title: error.stderr });
@ -147,66 +94,76 @@ export async function elevateCommand(
applicationName: string; applicationName: string;
}, },
): Promise<{ cancelled: boolean }> { ): Promise<{ cancelled: boolean }> {
// if we're running with elevated privileges, we can just spawn the command
if (await isElevated()) { if (await isElevated()) {
spawn(command[0], command.slice(1), { spawn(command[0], command.slice(1), {
env: options.env, env: options.env,
}); });
return { cancelled: false }; return { cancelled: false };
} }
const isWindows = os.platform() === 'win32'; const isWindows = os.platform() === 'win32';
const launchScript = createLaunchScript(
command[0], // Augment the command to pass the environment variables as args
command.slice(1), // Powershell (required to ask for elevated privileges) as of win10 cannot pass environment variables as a map, so we pass them as args
options.env, // For the sake of consistency, we do the same for Linux and macOS, but it's likely not required
); // Once we deprecate win10 we can move to a more elegant solution (passing the env to powershell)
return await withTmpFile( // const envFilter: string[] = [
{ // 'ETCHER_SERVER_ADDRESS',
keepOpen: false, // 'ETCHER_SERVER_PORT',
prefix: 'balena-etcher-electron-', // 'ETCHER_SERVER_ID',
postfix: '.cmd', // 'ETCHER_NO_SPAWN_UTIL',
}, // 'ETCHER_TERMINATE_TIMEOUT',
async ({ path }) => { // 'UV_THREADPOOL_SIZE',
await fs.writeFile(path, launchScript); // ];
if (isWindows) {
return elevateScriptWindows(path, options.applicationName, options.env); // const preparedCmd = [
} // command[0],
if ( // ...command.slice(1),
os.platform() === 'darwin' && // ...Object.keys(options.env)
semver.compare(os.release(), '19.0.0') >= 0 // .filter((key) => Object.prototype.hasOwnProperty.call(options.env, key))
) { // .filter((key) => envFilter.includes(key))
// >= macOS Catalina // .map((key) => `--${key}=${options.env[key]}`),
return elevateScriptCatalina(path); // ];
}
try { if (isWindows) {
return elevateScriptUnix(path, options.applicationName); return elevateScriptWindows(command, options.env);
} catch (error: any) { }
// We're hardcoding internal error messages declared by `sudo-prompt`. if (
// There doesn't seem to be a better way to handle these errors, so os.platform() === 'darwin' &&
// for now, we should make sure we double check if the error messages semver.compare(os.release(), '19.0.0') >= 0
// have changed every time we upgrade `sudo-prompt`. ) {
console.log('error', error); // >= macOS Catalina
if (_.includes(error.message, 'is not in the sudoers file')) { return elevateScriptCatalina(command, options.env);
throw errors.createUserError({ }
title: "Your user doesn't have enough privileges to proceed", try {
description: return elevateScriptUnix(command, options.applicationName, options.env);
'This application requires sudo privileges to be able to write to drives', } catch (error: any) {
}); // We're hardcoding internal error messages declared by `sudo-prompt`.
} else if (_.startsWith(error.message, 'Command failed:')) { // There doesn't seem to be a better way to handle these errors, so
throw errors.createUserError({ // for now, we should make sure we double check if the error messages
title: 'The elevated process died unexpectedly', // have changed every time we upgrade `sudo-prompt`.
description: `The process error code was ${error.code}`, console.log('error', error);
}); if (_.includes(error.message, 'is not in the sudoers file')) {
} else if (error.message === 'User did not grant permission.') { throw errors.createUserError({
return { cancelled: true }; title: "Your user doesn't have enough privileges to proceed",
} else if (error.message === 'No polkit authentication agent found.') { description:
throw errors.createUserError({ 'This application requires sudo privileges to be able to write to drives',
title: 'No polkit authentication agent found', });
description: } else if (_.startsWith(error.message, 'Command failed:')) {
'Please install a polkit authentication agent for your desktop environment of choice to continue', throw errors.createUserError({
}); title: 'The elevated process died unexpectedly',
} description: `The process error code was ${error.code}`,
throw error; });
} } else if (error.message === 'User did not grant permission.') {
}, return { cancelled: true };
); } else if (error.message === 'No polkit authentication agent found.') {
throw errors.createUserError({
title: 'No polkit authentication agent found',
description:
'Please install a polkit authentication agent for your desktop environment of choice to continue',
});
}
throw error;
}
} }

View File

@ -16,15 +16,10 @@
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import { join } from 'path'; import { join } from 'path';
import { env } from 'process';
// import { promisify } from "util";
import { supportedLocales } from '../../gui/app/i18n'; import { supportedLocales } from '../../gui/app/i18n';
// const execFileAsync = promisify(execFile);
const SUCCESSFUL_AUTH_MARKER = 'AUTHENTICATION SUCCEEDED'; const SUCCESSFUL_AUTH_MARKER = 'AUTHENTICATION SUCCEEDED';
const EXPECTED_SUCCESSFUL_AUTH_MARKER = `${SUCCESSFUL_AUTH_MARKER}\n`;
function getAskPassScriptPath(lang: string): string { function getAskPassScriptPath(lang: string): string {
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
@ -36,67 +31,68 @@ function getAskPassScriptPath(lang: string): string {
} }
export async function sudo( export async function sudo(
command: string, command: string[],
env: any,
): Promise<{ cancelled: boolean; stdout?: string; stderr?: string }> { ): Promise<{ cancelled: boolean; stdout?: string; stderr?: string }> {
try { let lang = Intl.DateTimeFormat().resolvedOptions().locale;
let lang = Intl.DateTimeFormat().resolvedOptions().locale; lang = lang.substr(0, 2);
lang = lang.substr(0, 2); if (supportedLocales.indexOf(lang) === -1) {
if (supportedLocales.indexOf(lang) > -1) { lang = 'en';
// language should be present }
} else {
// fallback to eng
lang = 'en';
}
// Build the shell command string
const shellCmd = `echo ${SUCCESSFUL_AUTH_MARKER} && ${command[0]} ${command
.slice(1)
.map((a) => a.replace(/"/g, '"'))
.join(' ')}`;
let elevated = 'pending';
try {
const elevateProcess = spawn( const elevateProcess = spawn(
'sudo', 'sudo',
['--askpass', 'sh', '-c', `echo ${SUCCESSFUL_AUTH_MARKER} && ${command}`], ['-E', '--askpass', 'sh', '-c', shellCmd],
{ {
// encoding: "utf8",
env: { env: {
...env,
PATH: env.PATH, PATH: env.PATH,
SUDO_ASKPASS: getAskPassScriptPath(lang), SUDO_ASKPASS: getAskPassScriptPath(lang),
}, },
}, },
); );
let elevated = 'pending';
elevateProcess.stdout.on('data', (data) => { elevateProcess.stdout.on('data', (data) => {
// console.log(`stdout: ${data}`);
if (data.toString().includes(SUCCESSFUL_AUTH_MARKER)) { if (data.toString().includes(SUCCESSFUL_AUTH_MARKER)) {
// if the first data comming out of the sudo command is the expected marker we resolve the promise
elevated = 'granted'; elevated = 'granted';
} else { } else {
// if the first data comming out of the sudo command is not the expected marker we reject the promise
elevated = 'rejected'; elevated = 'rejected';
} }
}); });
// we don't spawn or read stdout in the promise otherwise resolving stop the process // elevateProcess.stderr.on('data', (data) => {
return new Promise((resolve, reject) => { // console.log(`stderr: ${data}`);
const checkElevation = setInterval(() => { // });
if (elevated === 'granted') {
clearInterval(checkElevation);
resolve({ cancelled: false });
} else if (elevated === 'rejected') {
clearInterval(checkElevation);
resolve({ cancelled: true });
}
}, 300);
// if the elevation didn't occured in 30 seconds we reject the promise
setTimeout(() => {
clearInterval(checkElevation);
reject(new Error('Elevation timeout'));
}, 30000);
});
} catch (error: any) { } catch (error: any) {
if (error.code === 1) { console.error('Error starting sudo process', error);
if (!error.stdout.startsWith(EXPECTED_SUCCESSFUL_AUTH_MARKER)) { throw new Error('Error starting sudo process');
return { cancelled: true };
}
error.stdout = error.stdout.slice(EXPECTED_SUCCESSFUL_AUTH_MARKER.length);
}
throw error;
} }
return new Promise((resolve, reject) => {
const checkElevation = setInterval(() => {
console.log('elevated', elevated);
if (elevated === 'granted') {
clearInterval(checkElevation);
resolve({ cancelled: false });
} else if (elevated === 'rejected') {
clearInterval(checkElevation);
resolve({ cancelled: true });
}
}, 300);
setTimeout(() => {
clearInterval(checkElevation);
reject(new Error('Elevation timeout'));
}, 30000);
});
} }

View File

@ -7,30 +7,29 @@
* Copyright (c) 2015 Joran Dirk Greef * Copyright (c) 2015 Joran Dirk Greef
* Copyright (c) 2024 Balena * Copyright (c) 2024 Balena
* *
The MIT License (MIT) The MIT License (MIT)
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions: furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software. copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.
*/ */
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import { access, constants } from 'fs/promises'; import { access, constants } from 'fs/promises';
import { env } from 'process';
// const execFileAsync = promisify(execFile); // const execFileAsync = promisify(execFile);
@ -62,8 +61,9 @@ function escapeDoubleQuotes(escapeString: string) {
} }
export async function sudo( export async function sudo(
command: string, command: string[],
{ name }: { name: string }, { name }: { name: string },
env: any,
): Promise<{ cancelled: boolean; stdout?: string; stderr?: string }> { ): Promise<{ cancelled: boolean; stdout?: string; stderr?: string }> {
const linuxBinary: string = (await checkLinuxBinary()) as string; const linuxBinary: string = (await checkLinuxBinary()) as string;
if (!linuxBinary) { if (!linuxBinary) {
@ -72,11 +72,11 @@ export async function sudo(
const parameters = []; const parameters = [];
// Add kdesudo or pkexec specific parameters
if (/kdesudo/i.test(linuxBinary)) { if (/kdesudo/i.test(linuxBinary)) {
parameters.push( parameters.push(
'--comment', '--comment',
`"${name} wants to make changes. `"${name} wants to make changes.\nEnter your password to allow this."`,
Enter your password to allow this."`,
); );
parameters.push('-d'); // Do not show the command to be run in the dialog. parameters.push('-d'); // Do not show the command to be run in the dialog.
parameters.push('--'); parameters.push('--');
@ -84,15 +84,19 @@ export async function sudo(
parameters.push('--disable-internal-agent'); parameters.push('--disable-internal-agent');
} }
// Build the shell command string
const shellCmd = `echo ${SUCCESSFUL_AUTH_MARKER} && ${escapeDoubleQuotes(command[0])} ${command
.slice(1)
.map((a) => escapeDoubleQuotes(a))
.join(' ')}`;
parameters.push('/bin/bash'); parameters.push('/bin/bash');
parameters.push('-c'); parameters.push('-c');
parameters.push( parameters.push(shellCmd);
`echo ${SUCCESSFUL_AUTH_MARKER} && ${escapeDoubleQuotes(command)}`,
);
const elevateProcess = spawn(linuxBinary, parameters, { const elevateProcess = spawn(linuxBinary, parameters, {
// encoding: "utf8",
env: { env: {
...env,
PATH: env.PATH, PATH: env.PATH,
}, },
}); });
@ -110,17 +114,6 @@ export async function sudo(
} }
}); });
// elevateProcess.stderr.on('data', (data) => {
// // console.log(`stderr: ${data.toString()}`);
// // if (data.toString().includes(SUCCESSFUL_AUTH_MARKER)) {
// // // if the first data comming out of the sudo command is the expected marker we resolve the promise
// // elevated = 'granted';
// // } else {
// // // if the first data comming out of the sudo command is not the expected marker we reject the promise
// // elevated = 'refused';
// // }
// });
// we don't spawn or read stdout in the promise otherwise resolving stop the process // we don't spawn or read stdout in the promise otherwise resolving stop the process
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const checkElevation = setInterval(() => { const checkElevation = setInterval(() => {

View File

@ -7,212 +7,96 @@
* Copyright (c) 2015 Joran Dirk Greef * Copyright (c) 2015 Joran Dirk Greef
* Copyright (c) 2024 Balena * Copyright (c) 2024 Balena
* *
The MIT License (MIT) The MIT License (MIT)
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions: furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software. copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.
*/ */
import { spawn } from 'child_process'; import { spawn } from 'child_process';
// import { env } from 'process';
import { tmpdir } from 'os';
import { v4 as uuidv4 } from 'uuid';
import { join, sep } from 'path';
import { mkdir, writeFile, copyFile, readFile } from 'fs/promises';
/**
* TODO:
* Migrate, modernize and clenup the windows elevation code from the old @balena/sudo-prompt package in a similar way to linux-sudo.ts and catalina-sudo files.
*/
export async function sudo( export async function sudo(
command: string, command: string[],
_name: string,
env: any, env: any,
): Promise<{ cancelled: boolean; stdout?: string; stderr?: string }> { ): Promise<{ cancelled: boolean; stdout?: string; stderr?: string }> {
const uuid = uuidv4();
const temp = tmpdir();
if (!temp) {
throw new Error('os.tmpdir() not defined.');
}
const tmpFolder = join(temp, uuid);
if (/"/.test(tmpFolder)) {
// We expect double quotes to be reserved on Windows.
// Even so, we test for this and abort if they are present.
throw new Error('instance.path cannot contain double-quotes.');
}
const executeScriptPath = join(tmpFolder, 'execute.bat');
const commandScriptPath = join(tmpFolder, 'command.bat');
const stdoutPath = join(tmpFolder, 'stdout');
const stderrPath = join(tmpFolder, 'stderr');
const statusPath = join(tmpFolder, 'status');
const SUCCESSFUL_AUTH_MARKER = 'AUTHENTICATION SUCCEEDED';
try { try {
await mkdir(tmpFolder);
// WindowsWriteExecuteScript(instance, end)
const executeScript = `
@echo off\r\n
call "${commandScriptPath}" > "${stdoutPath}" 2> "${stderrPath}"\r\n
(echo %ERRORLEVEL%) > "${statusPath}"
`;
await writeFile(executeScriptPath, executeScript, 'utf-8');
// WindowsWriteCommandScript(instance, end) // WindowsWriteCommandScript(instance, end)
const cwd = process.cwd();
if (/"/.test(cwd)) {
// We expect double quotes to be reserved on Windows.
// Even so, we test for this and abort if they are present.
throw new Error('process.cwd() cannot contain double-quotes.');
}
const commandScriptArray = [];
commandScriptArray.push('@echo off');
// Set code page to UTF-8:
commandScriptArray.push('chcp 65001>nul');
// Preserve current working directory:
// We pass /d as an option in case the cwd is on another drive (issue 70).
commandScriptArray.push(`cd /d "${cwd}"`);
// Export environment variables:
for (const key in env) {
// "The characters <, >, |, &, ^ are special command shell characters, and
// they must be preceded by the escape character (^) or enclosed in
// quotation marks. If you use quotation marks to enclose a string that
// contains one of the special characters, the quotation marks are set as
// part of the environment variable value."
// In other words, Windows assigns everything that follows the equals sign
// to the value of the variable, whereas Unix systems ignore double quotes.
if (Object.prototype.hasOwnProperty.call(env, key)) {
const value = env[key];
commandScriptArray.push(
`set ${key}=${value!.replace(/([<>\\|&^])/g, '^$1')}`,
);
}
}
commandScriptArray.push(`echo ${SUCCESSFUL_AUTH_MARKER}`);
commandScriptArray.push(command);
await writeFile(
commandScriptPath,
commandScriptArray.join('\r\n'),
'utf-8',
);
// WindowsCopyCmd(instance, end)
if (windowsNeedsCopyCmd(tmpFolder)) {
// Work around https://github.com/jorangreef/sudo-prompt/issues/97
// Powershell can't properly escape amperstands in paths.
// We work around this by copying cmd.exe in our temporary folder and running
// it from here (see WindowsElevate below).
// That way, we don't have to pass the path containing the amperstand at all.
// A symlink would probably work too but you have to be an administrator in
// order to create symlinks on Windows.
await copyFile(
join(process.env.SystemRoot!, 'System32', 'cmd.exe'),
join(tmpFolder, 'cmd.exe'),
);
}
// WindowsElevate(instance, end)
// We used to use this for executing elevate.vbs:
// var command = 'cscript.exe //NoLogo "' + instance.pathElevate + '"';
const spawnCommand = []; const spawnCommand = [];
// spawnCommand.push("powershell.exe") // as we use spawn this one is out of the array
spawnCommand.push('Start-Process'); spawnCommand.push('Start-Process');
spawnCommand.push('-FilePath'); spawnCommand.push('-FilePath');
const options: any = { encoding: 'utf8' };
if (windowsNeedsCopyCmd(tmpFolder)) { // Escape characters for cmd using double quotes:
// Node.path.join('.', 'cmd.exe') would return 'cmd.exe' // Escape characters for PowerShell using single quotes:
spawnCommand.push(['.', 'cmd.exe'].join(sep)); // Escape single quotes for PowerShell using backtick:
spawnCommand.push('-ArgumentList'); // See: https://ss64.com/ps/syntax-esc.html
spawnCommand.push('"/C","execute.bat"'); spawnCommand.push(`'${command[0].replace(/'/g, "`'")}'`);
options.cwd = tmpFolder; spawnCommand.push('-ArgumentList');
} else {
// Escape characters for cmd using double quotes: // Join and escape arguments for PowerShell
// Escape characters for PowerShell using single quotes: spawnCommand.push(
// Escape single quotes for PowerShell using backtick: `'${command
// See: https://ss64.com/ps/syntax-esc.html .slice(1)
spawnCommand.push(`'${executeScriptPath.replace(/'/g, "`'")}'`); .map((a) => a.replace(/'/g, "`'"))
} .join(' ')}'`,
);
spawnCommand.push('-WindowStyle hidden'); spawnCommand.push('-WindowStyle hidden');
spawnCommand.push('-Verb runAs'); spawnCommand.push('-Verb runAs');
spawn('powershell.exe', spawnCommand); const child = spawn('powershell.exe', spawnCommand, {
env,
// setTimeout(() => {elevated = "granted"}, 5000)
// we don't spawn or read stdout in the promise otherwise resolving stop the process
return new Promise((resolve, reject) => {
const checkElevation = setInterval(async () => {
try {
const result = await readFile(stdoutPath, 'utf-8');
const error = await readFile(stderrPath, 'utf-8');
if (error && error !== '') {
throw new Error(error);
}
// TODO: should track something more generic
if (result.includes(SUCCESSFUL_AUTH_MARKER)) {
clearInterval(checkElevation);
resolve({ cancelled: false });
}
} catch (error) {
console.log(
'Error while reading flasher elevation script output',
error,
);
}
}, 1000);
// if the elevation didn't occured in 30 seconds we reject the promise
setTimeout(() => {
clearInterval(checkElevation);
reject(new Error('Elevation timeout'));
}, 30000);
}); });
// WindowsWaitForStatus(instance, end) let result = { status: 'waiting' };
// WindowsResult(instance, end) child.on('close', (code) => {
if (code === 0) {
// User accepted UAC, process started
result = { status: 'granted' };
} else {
// User cancelled or error occurred
result = { status: 'cancelled' };
}
});
child.on('error', (err) => {
result = { status: err.message };
});
// we don't spawn directly in the promise otherwise resolving stop the process
return new Promise((resolve, reject) => {
setTimeout(() => {
if (result.status === 'waiting') {
// Still waiting for user input, don't resolve or reject yet
return;
} else if (result.status === 'granted') {
// User accepted the UAC prompt, process started
resolve({ cancelled: false });
} else if (result.status === 'cancelled') {
// User cancelled the UAC prompt
resolve({ cancelled: true });
} else {
// An error occurred, reject the promise
reject(new Error(`Elevation failed: ${result.status}`));
}
}, 300);
});
} catch (error) { } catch (error) {
throw new Error(`Can't elevate process ${error}`); throw new Error(`Can't elevate process ${error}`);
} finally {
// TODO: cleanup
// // Remove(instance.path, function (errorRemove) {
// // if (error) return callback(error)
// // if (errorRemove) return callback(errorRemove)
// // callback(undefined, stdout, stderr)
} }
} }
function windowsNeedsCopyCmd(path: string) {
const specialChars = ['&', '`', "'", '"', '<', '>', '|', '^'];
for (const specialChar of specialChars) {
if (path.includes(specialChar)) {
return true;
}
}
return false;
}

View File

@ -29,6 +29,28 @@ import { getSourceMetadata } from './source-metadata';
import type { DrivelistDrive } from '../shared/drive-constraints'; import type { DrivelistDrive } from '../shared/drive-constraints';
import type { SourceMetadata } from '../shared/typings/source-selector'; import type { SourceMetadata } from '../shared/typings/source-selector';
// Utility to parse --key=value arguments into process.env if not already set
function injectEnvFromArgs() {
for (const arg of process.argv.slice(2)) {
const match = arg.match(/^--([^=]+)=(.*)$/);
if (match) {
const key = match[1];
const value = match[2];
if (process.env[key] === undefined) {
process.env[key] = value;
}
}
}
}
// Inject env vars from arguments if not already present
injectEnvFromArgs();
console.log(
'Etcher child process started with the following environment variables:',
);
console.log(JSON.stringify(process.env, null, 2));
const ETCHER_SERVER_ADDRESS = process.env.ETCHER_SERVER_ADDRESS as string; const ETCHER_SERVER_ADDRESS = process.env.ETCHER_SERVER_ADDRESS as string;
const ETCHER_SERVER_PORT = process.env.ETCHER_SERVER_PORT as string; const ETCHER_SERVER_PORT = process.env.ETCHER_SERVER_PORT as string;
// const ETCHER_SERVER_ID = process.env.ETCHER_SERVER_ID as string; // const ETCHER_SERVER_ID = process.env.ETCHER_SERVER_ID as string;

56149
npm-shrinkwrap.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,153 +1,153 @@
{ {
"name": "balena-etcher", "name": "balena-etcher",
"private": true, "private": true,
"displayName": "balenaEtcher", "displayName": "balenaEtcher",
"productName": "balenaEtcher", "productName": "balenaEtcher",
"version": "2.1.3", "version": "2.1.3",
"packageType": "local", "packageType": "local",
"main": ".webpack/main", "main": ".webpack/main",
"description": "Flash OS images to SD cards and USB drives, safely and easily.", "description": "Flash OS images to SD cards and USB drives, safely and easily.",
"productDescription": "Etcher is a powerful OS image flasher built with web technologies to ensure flashing an SDCard or USB drive is a pleasant and safe experience. It protects you from accidentally writing to your hard-drives, ensures every byte of data was written correctly and much more.", "productDescription": "Etcher is a powerful OS image flasher built with web technologies to ensure flashing an SDCard or USB drive is a pleasant and safe experience. It protects you from accidentally writing to your hard-drives, ensures every byte of data was written correctly and much more.",
"homepage": "https://github.com/balena-io/etcher", "homepage": "https://github.com/balena-io/etcher",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git@github.com:balena-io/etcher.git" "url": "git@github.com:balena-io/etcher.git"
}, },
"scripts": { "scripts": {
"prettify": "prettier --write lib/**/*.css && balena-lint --fix --typescript typings lib tests forge.config.ts forge.sidecar.ts webpack.config.ts", "prettify": "prettier --write lib/**/*.css && balena-lint --fix --typescript typings lib tests forge.config.ts forge.sidecar.ts webpack.config.ts",
"lint": "npm run prettify && catch-uncommitted", "lint": "npm run prettify && catch-uncommitted",
"test": "echo 'Only use custom tests; if you want to test locally, use `npm run wdio`' && exit 0", "test": "echo 'Only use custom tests; if you want to test locally, use `npm run wdio`' && exit 0",
"package": "electron-forge package", "package": "electron-forge package",
"start": "electron-forge start", "start": "electron-forge start",
"make": "electron-forge make", "make": "electron-forge make",
"wdio": "xvfb-maybe wdio run ./wdio.conf.ts" "wdio": "xvfb-maybe wdio run ./wdio.conf.ts"
}, },
"husky": { "husky": {
"hooks": { "hooks": {
"pre-commit": "npm run prettify" "pre-commit": "npm run prettify"
} }
}, },
"author": "Balena Ltd. <hello@balena.io>", "author": "Balena Ltd. <hello@balena.io>",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@electron/remote": "^2.1.2", "@electron/remote": "^2.1.2",
"@fortawesome/fontawesome-free": "^6.5.2", "@fortawesome/fontawesome-free": "^6.5.2",
"@ronomon/direct-io": "^3.0.1", "@ronomon/direct-io": "^3.0.1",
"@sentry/electron": "^4.24.0", "@sentry/electron": "^4.24.0",
"axios": "^1.6.8", "axios": "^1.6.8",
"debug": "4.3.4", "debug": "4.3.4",
"drivelist": "^12.0.2", "drivelist": "^12.0.2",
"electron-squirrel-startup": "^1.0.0", "electron-squirrel-startup": "^1.0.0",
"electron-updater": "6.1.8", "electron-updater": "6.1.8",
"etcher-sdk": "9.1.2", "etcher-sdk": "9.1.2",
"i18next": "23.11.2", "i18next": "23.11.2",
"immutable": "3.8.2", "immutable": "3.8.2",
"lodash": "4.17.21", "lodash": "4.17.21",
"outdent": "0.8.0", "outdent": "0.8.0",
"path-is-inside": "1.0.2", "path-is-inside": "1.0.2",
"pretty-bytes": "6.1.1", "pretty-bytes": "6.1.1",
"react": "17.0.2", "react": "17.0.2",
"react-dom": "17.0.2", "react-dom": "17.0.2",
"react-i18next": "13.5.0", "react-i18next": "13.5.0",
"redux": "4.2.1", "redux": "4.2.1",
"rendition": "35.2.0", "rendition": "35.2.0",
"semver": "7.6.0", "semver": "7.6.0",
"styled-components": "5.3.6", "styled-components": "5.3.6",
"sys-class-rgb-led": "3.0.1", "sys-class-rgb-led": "3.0.1",
"uuid": "9.0.1", "uuid": "9.0.1",
"ws": "^8.16.0" "ws": "^8.16.0"
}, },
"devDependencies": { "devDependencies": {
"@balena/lint": "8.0.2", "@balena/lint": "8.0.2",
"@electron-forge/cli": "7.4.0", "@electron-forge/cli": "7.4.0",
"@electron-forge/maker-deb": "7.4.0", "@electron-forge/maker-deb": "7.4.0",
"@electron-forge/maker-dmg": "7.4.0", "@electron-forge/maker-dmg": "7.4.0",
"@electron-forge/maker-rpm": "7.4.0", "@electron-forge/maker-rpm": "7.4.0",
"@electron-forge/maker-squirrel": "7.4.0", "@electron-forge/maker-squirrel": "7.4.0",
"@electron-forge/maker-zip": "7.4.0", "@electron-forge/maker-zip": "7.4.0",
"@electron-forge/plugin-auto-unpack-natives": "7.4.0", "@electron-forge/plugin-auto-unpack-natives": "7.4.0",
"@electron-forge/plugin-webpack": "7.4.0", "@electron-forge/plugin-webpack": "7.4.0",
"@reforged/maker-appimage": "3.3.2", "@reforged/maker-appimage": "3.3.2",
"@svgr/webpack": "8.1.0", "@svgr/webpack": "8.1.0",
"@types/chai": "4.3.14", "@types/chai": "4.3.14",
"@types/debug": "^4.1.12", "@types/debug": "^4.1.12",
"@types/mime-types": "2.1.4", "@types/mime-types": "2.1.4",
"@types/node": "^20.11.6", "@types/node": "^20.11.6",
"@types/react": "17.0.2", "@types/react": "17.0.2",
"@types/react-dom": "17.0.2", "@types/react-dom": "17.0.2",
"@types/semver": "7.5.8", "@types/semver": "7.5.8",
"@types/sinon": "17.0.3", "@types/sinon": "17.0.3",
"@types/tmp": "0.2.6", "@types/tmp": "0.2.6",
"@vercel/webpack-asset-relocator-loader": "1.7.3", "@vercel/webpack-asset-relocator-loader": "1.7.3",
"@wdio/cli": "^8.36.1", "@wdio/cli": "^8.36.1",
"@wdio/local-runner": "^8.36.1", "@wdio/local-runner": "^8.36.1",
"@wdio/mocha-framework": "^8.36.1", "@wdio/mocha-framework": "^8.36.1",
"@wdio/spec-reporter": "^8.36.1", "@wdio/spec-reporter": "^8.36.1",
"@yao-pkg/pkg": "^5.11.5", "@yao-pkg/pkg": "^6.5.1",
"catch-uncommitted": "^2.0.0", "catch-uncommitted": "^2.0.0",
"chai": "4.3.10", "chai": "4.3.10",
"css-loader": "5.2.7", "css-loader": "5.2.7",
"electron": "30.0.1", "electron": "30.0.1",
"file-loader": "6.2.0", "file-loader": "6.2.0",
"husky": "8.0.3", "husky": "8.0.3",
"native-addon-loader": "2.0.1", "native-addon-loader": "2.0.1",
"node-loader": "^2.0.0", "node-loader": "^2.0.0",
"sinon": "^17.0.1", "sinon": "^17.0.1",
"string-replace-loader": "3.1.0", "string-replace-loader": "3.1.0",
"style-loader": "3.3.3", "style-loader": "3.3.3",
"ts-loader": "^9.5.1", "ts-loader": "^9.5.1",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tslib": "2.6.2", "tslib": "2.6.2",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"url-loader": "4.1.1", "url-loader": "4.1.1",
"wdio-electron-service": "^6.4.1", "wdio-electron-service": "^6.4.1",
"xvfb-maybe": "^0.2.1" "xvfb-maybe": "^0.2.1"
}, },
"hostDependencies": { "hostDependencies": {
"debian": [ "debian": [
"libasound2", "libasound2",
"libatk1.0-0", "libatk1.0-0",
"libc6", "libc6",
"libcairo2", "libcairo2",
"libcups2", "libcups2",
"libdbus-1-3", "libdbus-1-3",
"libexpat1", "libexpat1",
"libfontconfig1", "libfontconfig1",
"libfreetype6", "libfreetype6",
"libgbm1", "libgbm1",
"libgcc1", "libgcc1",
"libgdk-pixbuf2.0-0", "libgdk-pixbuf2.0-0",
"libglib2.0-0", "libglib2.0-0",
"libgtk-3-0", "libgtk-3-0",
"liblzma5", "liblzma5",
"libnotify4", "libnotify4",
"libnspr4", "libnspr4",
"libnss3", "libnss3",
"libpango1.0-0 | libpango-1.0-0", "libpango1.0-0 | libpango-1.0-0",
"libstdc++6", "libstdc++6",
"libx11-6", "libx11-6",
"libxcomposite1", "libxcomposite1",
"libxcursor1", "libxcursor1",
"libxdamage1", "libxdamage1",
"libxext6", "libxext6",
"libxfixes3", "libxfixes3",
"libxi6", "libxi6",
"libxrandr2", "libxrandr2",
"libxrender1", "libxrender1",
"libxss1", "libxss1",
"libxtst6", "libxtst6",
"polkit-1-auth-agent | policykit-1-gnome | polkit-kde-1" "polkit-1-auth-agent | policykit-1-gnome | polkit-kde-1"
] ]
}, },
"engines": { "engines": {
"node": ">=20 <21" "node": ">=20 <21"
}, },
"versionist": { "versionist": {
"publishedAt": "2025-05-15T18:09:56.320Z" "publishedAt": "2025-05-15T18:09:56.320Z"
}, },
"optionalDependencies": { "optionalDependencies": {
"bufferutil": "^4.0.8", "bufferutil": "^4.0.8",
"utf-8-validate": "^5.0.10", "utf-8-validate": "^5.0.10",
"winusb-driver-generator": "2.1.2" "winusb-driver-generator": "2.1.2"
} }
} }