From f3123f3cbe0159c624412ec2f162d01e079d314e Mon Sep 17 00:00:00 2001 From: Edwin Joassart Date: Wed, 16 Jul 2025 11:27:06 +0200 Subject: [PATCH] patch: refactor permission code --- lib/gui/app/modules/api.ts | 22 ++- lib/gui/app/modules/progress-status.ts | 2 - lib/shared/permissions.ts | 215 ++++++++------------- lib/shared/sudo/darwin.ts | 90 +++++---- lib/shared/sudo/linux.ts | 88 +++------ lib/shared/sudo/windows.ts | 253 +++++++------------------ lib/util/api.ts | 22 +++ npm-shrinkwrap.json | 2 +- package.json | 2 +- tests/shared/permissions.spec.ts | 88 --------- 10 files changed, 244 insertions(+), 540 deletions(-) delete mode 100644 tests/shared/permissions.spec.ts diff --git a/lib/gui/app/modules/api.ts b/lib/gui/app/modules/api.ts index a5514fdf..886eef1e 100644 --- a/lib/gui/app/modules/api.ts +++ b/lib/gui/app/modules/api.ts @@ -98,7 +98,8 @@ async function connectToChildProcess( ): Promise { return new Promise((resolve, reject) => { // 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); const url = `ws://${etcherServerAddress}:${etcherServerPort}`; @@ -196,9 +197,9 @@ async function spawnChildAndConnect({ `etcher-${Math.random().toString(36).substring(7)}`; console.log( - `Spawning ${ + `Starting ${ withPrivileges ? 'priviledged' : 'unpriviledged' - } sidecar on port ${etcherServerPort}`, + } flasher sidecar on port ${etcherServerPort}`, ); // spawn the child process, which will act as the ws server @@ -212,11 +213,11 @@ async function spawnChildAndConnect({ etcherServerPort, ); if (result.cancelled) { - throw new Error('Spwaning the child process was cancelled'); + throw new Error('Starting flasher sidecar process was cancelled'); } } catch (error) { - console.error('Error spawning child process', error); - throw new Error('Error spawning the child process'); + console.error('Error starting flasher sidecar process', error); + throw new Error('Error starting flasher sidecar process'); } } @@ -232,7 +233,7 @@ async function spawnChildAndConnect({ if (failed) { retry++; 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) => setTimeout(resolve, connectionRetryDelay), @@ -241,10 +242,11 @@ async function spawnChildAndConnect({ } 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) { - console.error('Error connecting to child process', error); - throw new Error('Connection to etcher-util failed'); + console.error('Error connecting to sidecar flasher process process', error); + throw new Error('Connection to sidecar flasher process failed'); } } diff --git a/lib/gui/app/modules/progress-status.ts b/lib/gui/app/modules/progress-status.ts index 02ec2230..b0495634 100644 --- a/lib/gui/app/modules/progress-status.ts +++ b/lib/gui/app/modules/progress-status.ts @@ -34,8 +34,6 @@ export function fromFlashState({ status: string; position?: string; } { - console.log(i18next.t('progress.starting')); - if (type === undefined) { return { status: i18next.t('progress.starting') }; } else if (type === 'decompressing') { diff --git a/lib/shared/permissions.ts b/lib/shared/permissions.ts index acd67192..529d2211 100755 --- a/lib/shared/permissions.ts +++ b/lib/shared/permissions.ts @@ -14,16 +14,7 @@ * 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 { withTmpFile } from 'etcher-sdk/build/tmp'; -import { promises as fs } from 'fs'; import { promisify } from 'util'; import * as _ from 'lodash'; import * as os from 'os'; @@ -41,6 +32,32 @@ const execAsync = promisify(exec); */ const UNIX_SUPERUSER_USER_ID = 0; +// Augment the command to pass the environment variables as args +// This is required because both windows and linux sudo commands strips the environment +// variables when running the elevated command, so we need to pass them as arguments +function commandWithEnv( + command: string[], + env: _.Dictionary, +): string[] { + const envFilter: string[] = [ + 'ETCHER_SERVER_ADDRESS', + 'ETCHER_SERVER_PORT', + 'ETCHER_SERVER_ID', + 'ETCHER_NO_SPAWN_UTIL', + 'ETCHER_TERMINATE_TIMEOUT', + 'UV_THREADPOOL_SIZE', + ]; + + return [ + command[0], + ...command.slice(1), + ...Object.keys(env) + .filter((key) => Object.prototype.hasOwnProperty.call(env, key)) + .filter((key) => envFilter.includes(key)) + .map((key) => `--${key}=${env[key]}`), + ]; +} + export async function isElevated(): Promise { if (os.platform() === 'win32') { // `fltmc` is available on WinPE, XP, Vista, 7, 8, and 10 @@ -66,80 +83,6 @@ export function isElevatedUnixSync(): boolean { 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 { - 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( - path: string, - name: string, - env: any, -): Promise<{ cancelled: false }> { - // '&' needs to be escaped here (but not when written to a .cmd file) - const cmd = ['cmd', '/c', escapeParamCmd(path).replace(/&/g, '^&')].join(' '); - await winSudo(cmd, name, env); - return { cancelled: false }; -} - -async function elevateScriptUnix( - path: string, - name: string, -): Promise<{ cancelled: boolean }> { - const cmd = ['bash', escapeSh(path)].join(' '); - await linuxSudo(cmd, { name }); - return { cancelled: false }; -} - -async function elevateScriptCatalina( - path: string, -): Promise<{ cancelled: boolean }> { - const cmd = ['bash', escapeSh(path)].join(' '); - try { - const { cancelled } = await darwinSudo(cmd); - return { cancelled }; - } catch (error: any) { - throw errors.createError({ title: error.stderr }); - } -} - export async function elevateCommand( command: string[], options: { @@ -147,66 +90,60 @@ export async function elevateCommand( applicationName: string; }, ): Promise<{ cancelled: boolean }> { + // if we're running with elevated privileges, we can just spawn the command if (await isElevated()) { spawn(command[0], command.slice(1), { env: options.env, }); return { cancelled: false }; } - const isWindows = os.platform() === 'win32'; - const launchScript = createLaunchScript( - command[0], - command.slice(1), - options.env, - ); - return await withTmpFile( - { - keepOpen: false, - prefix: 'balena-etcher-electron-', - postfix: '.cmd', - }, - async ({ path }) => { - await fs.writeFile(path, launchScript); - if (isWindows) { - return elevateScriptWindows(path, options.applicationName, options.env); - } - if ( - os.platform() === 'darwin' && - semver.compare(os.release(), '19.0.0') >= 0 - ) { - // >= macOS Catalina - return elevateScriptCatalina(path); - } - try { - return elevateScriptUnix(path, options.applicationName); - } catch (error: any) { - // We're hardcoding internal error messages declared by `sudo-prompt`. - // There doesn't seem to be a better way to handle these errors, so - // for now, we should make sure we double check if the error messages - // have changed every time we upgrade `sudo-prompt`. - console.log('error', error); - if (_.includes(error.message, 'is not in the sudoers file')) { - throw errors.createUserError({ - title: "Your user doesn't have enough privileges to proceed", - description: - 'This application requires sudo privileges to be able to write to drives', - }); - } else if (_.startsWith(error.message, 'Command failed:')) { - throw errors.createUserError({ - title: 'The elevated process died unexpectedly', - description: `The process error code was ${error.code}`, - }); - } 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; - } - }, - ); + + try { + if (os.platform() === 'win32') { + const { cancelled } = await winSudo(commandWithEnv(command, options.env)); + return { cancelled }; + } + if ( + os.platform() === 'darwin' && + semver.compare(os.release(), '19.0.0') >= 0 + ) { + // >= macOS Catalina + const { cancelled } = await darwinSudo(command, options.env); + return { cancelled }; + } + } catch (error: any) { + throw errors.createError({ title: error.stderr }); + } + + try { + const { cancelled } = await linuxSudo(commandWithEnv(command, options.env)); + return { cancelled }; + } catch (error: any) { + // We're hardcoding internal error messages declared by `sudo-prompt`. + // There doesn't seem to be a better way to handle these errors, so + // for now, we should make sure we double check if the error messages + // have changed every time we upgrade `sudo-prompt`. + console.log('error', error); + if (_.includes(error.message, 'is not in the sudoers file')) { + throw errors.createUserError({ + title: "Your user doesn't have enough privileges to proceed", + description: + 'This application requires sudo privileges to be able to write to drives', + }); + } else if (_.startsWith(error.message, 'Command failed:')) { + throw errors.createUserError({ + title: 'The elevated process died unexpectedly', + description: `The process error code was ${error.code}`, + }); + } 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; + } } diff --git a/lib/shared/sudo/darwin.ts b/lib/shared/sudo/darwin.ts index 3bc13b45..627c86ab 100644 --- a/lib/shared/sudo/darwin.ts +++ b/lib/shared/sudo/darwin.ts @@ -1,5 +1,5 @@ /* - * Copyright 2019 balena.io + * Copyright 2025 balena.io * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,15 +16,10 @@ import { spawn } from 'child_process'; import { join } from 'path'; -import { env } from 'process'; -// import { promisify } from "util"; import { supportedLocales } from '../../gui/app/i18n'; -// const execFileAsync = promisify(execFile); - const SUCCESSFUL_AUTH_MARKER = 'AUTHENTICATION SUCCEEDED'; -const EXPECTED_SUCCESSFUL_AUTH_MARKER = `${SUCCESSFUL_AUTH_MARKER}\n`; function getAskPassScriptPath(lang: string): string { if (process.env.NODE_ENV === 'development') { @@ -36,67 +31,68 @@ function getAskPassScriptPath(lang: string): string { } export async function sudo( - command: string, + command: string[], + env: any, ): Promise<{ cancelled: boolean; stdout?: string; stderr?: string }> { - try { - let lang = Intl.DateTimeFormat().resolvedOptions().locale; - lang = lang.substr(0, 2); - if (supportedLocales.indexOf(lang) > -1) { - // language should be present - } else { - // fallback to eng - lang = 'en'; - } + let lang = Intl.DateTimeFormat().resolvedOptions().locale; + lang = lang.substr(0, 2); + if (supportedLocales.indexOf(lang) === -1) { + lang = 'en'; + } + // Build the shell command string + const shellCmd = `echo ${SUCCESSFUL_AUTH_MARKER} && ${command[0]} ${command + .slice(1) + .map((a) => a.replace(/\\/g, '\\\\').replace(/"/g, '\\"')) + .join(' ')}`; + + let elevated = 'pending'; + + try { const elevateProcess = spawn( 'sudo', - ['--askpass', 'sh', '-c', `echo ${SUCCESSFUL_AUTH_MARKER} && ${command}`], + ['-E', '--askpass', 'sh', '-c', shellCmd], { - // encoding: "utf8", env: { + ...env, PATH: env.PATH, SUDO_ASKPASS: getAskPassScriptPath(lang), }, }, ); - let elevated = 'pending'; - elevateProcess.stdout.on('data', (data) => { + // console.log(`stdout: ${data}`); 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 = 'rejected'; } }); - // we don't spawn or read stdout in the promise otherwise resolving stop the process - return new Promise((resolve, reject) => { - 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); - }); + // elevateProcess.stderr.on('data', (data) => { + // console.log(`stderr: ${data}`); + // }); } catch (error: any) { - if (error.code === 1) { - if (!error.stdout.startsWith(EXPECTED_SUCCESSFUL_AUTH_MARKER)) { - return { cancelled: true }; - } - error.stdout = error.stdout.slice(EXPECTED_SUCCESSFUL_AUTH_MARKER.length); - } - throw error; + console.error('Error starting sudo process', error); + throw new Error('Error starting sudo process'); } + + 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); + }); } diff --git a/lib/shared/sudo/linux.ts b/lib/shared/sudo/linux.ts index 18d717b3..5d0c58e7 100644 --- a/lib/shared/sudo/linux.ts +++ b/lib/shared/sudo/linux.ts @@ -1,38 +1,21 @@ -/* - * This is heavily inspired (read: a ripof) https://github.com/balena-io-modules/sudo-prompt - * Which was a fork of https://github.com/jorangreef/sudo-prompt - * - * This and the original code was released under The MIT License (MIT) - * - * Copyright (c) 2015 Joran Dirk Greef - * Copyright (c) 2024 Balena - * - The MIT License (MIT) - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - 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 - SOFTWARE. +/* + * Copyright 2025 balena.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. */ import { spawn } from 'child_process'; import { access, constants } from 'fs/promises'; -import { env } from 'process'; - -// const execFileAsync = promisify(execFile); const SUCCESSFUL_AUTH_MARKER = 'AUTHENTICATION SUCCEEDED'; @@ -62,40 +45,30 @@ function escapeDoubleQuotes(escapeString: string) { } export async function sudo( - command: string, - { name }: { name: string }, + command: string[], ): Promise<{ cancelled: boolean; stdout?: string; stderr?: string }> { const linuxBinary: string = (await checkLinuxBinary()) as string; if (!linuxBinary) { - throw new Error('Unable to find pkexec or kdesudo.'); + throw new Error('Unable to find pkexec.'); } const parameters = []; - if (/kdesudo/i.test(linuxBinary)) { - parameters.push( - '--comment', - `"${name} wants to make changes. - Enter your password to allow this."`, - ); - parameters.push('-d'); // Do not show the command to be run in the dialog. - parameters.push('--'); - } else if (/pkexec/i.test(linuxBinary)) { + // Add pkexec specific parameters + if (/pkexec/i.test(linuxBinary)) { parameters.push('--disable-internal-agent'); } + // Build the shell command string + const shellCmd = `echo ${SUCCESSFUL_AUTH_MARKER} && ${command + .map((a) => escapeDoubleQuotes(a)) + .join(' ')}`; + parameters.push('/bin/bash'); parameters.push('-c'); - parameters.push( - `echo ${SUCCESSFUL_AUTH_MARKER} && ${escapeDoubleQuotes(command)}`, - ); + parameters.push(shellCmd); - const elevateProcess = spawn(linuxBinary, parameters, { - // encoding: "utf8", - env: { - PATH: env.PATH, - }, - }); + const elevateProcess = spawn(linuxBinary, parameters); let elevated = ''; @@ -110,17 +83,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 return new Promise((resolve, reject) => { const checkElevation = setInterval(() => { diff --git a/lib/shared/sudo/windows.ts b/lib/shared/sudo/windows.ts index 41a4c1c3..d60e1617 100644 --- a/lib/shared/sudo/windows.ts +++ b/lib/shared/sudo/windows.ts @@ -1,190 +1,85 @@ -/* - * This is heavily inspired (read: a ripof) https://github.com/balena-io-modules/sudo-prompt - * Which was a fork of https://github.com/jorangreef/sudo-prompt - * - * This and the original code was released under The MIT License (MIT) - * - * Copyright (c) 2015 Joran Dirk Greef - * Copyright (c) 2024 Balena - * - The MIT License (MIT) - - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - 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 - SOFTWARE. +/* + * Copyright 2025 balena.io + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. */ 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( - command: string, - _name: string, - env: any, + command: 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 { - await mkdir(tmpFolder); + // Powershell (required to ask for elevated privileges) as of win10 + // cannot pass environment variables as a map, so we pass them as args + // this is a workaround as we can't use an equivalent of `sudo -E` on Windows - // 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) - 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 = []; - // spawnCommand.push("powershell.exe") // as we use spawn this one is out of the array spawnCommand.push('Start-Process'); spawnCommand.push('-FilePath'); - const options: any = { encoding: 'utf8' }; - if (windowsNeedsCopyCmd(tmpFolder)) { - // Node.path.join('.', 'cmd.exe') would return 'cmd.exe' - spawnCommand.push(['.', 'cmd.exe'].join(sep)); - spawnCommand.push('-ArgumentList'); - spawnCommand.push('"/C","execute.bat"'); - options.cwd = tmpFolder; - } else { - // Escape characters for cmd using double quotes: - // Escape characters for PowerShell using single quotes: - // Escape single quotes for PowerShell using backtick: - // See: https://ss64.com/ps/syntax-esc.html - spawnCommand.push(`'${executeScriptPath.replace(/'/g, "`'")}'`); - } + + // Escape characters for cmd using double quotes: + // Escape characters for PowerShell using single quotes: + // Escape single quotes for PowerShell using backtick: + // See: https://ss64.com/ps/syntax-esc.html + spawnCommand.push(`'${command[0].replace(/'/g, "`'")}'`); + + spawnCommand.push('-ArgumentList'); + + // Join and escape arguments for PowerShell + spawnCommand.push( + `'${command + .slice(1) + .map((a) => a.replace(/'/g, "`'")) + .join(' ')}'`, + ); spawnCommand.push('-WindowStyle hidden'); spawnCommand.push('-Verb runAs'); - spawn('powershell.exe', spawnCommand); + const child = spawn('powershell.exe', spawnCommand); - // setTimeout(() => {elevated = "granted"}, 5000) + let result = { status: 'waiting' }; + + child.on('close', (code) => { + if (code === 0) { + // User accepted UAC, process started + console.log('UAC accepted, process started'); + result = { status: 'granted' }; + } else { + // User cancelled or error occurred + console.log('UAC 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 - // 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, - ); + const checkElevation = setInterval(() => { + if (result.status === 'waiting') { + return; + } else if (result.status === 'granted') { + clearInterval(checkElevation); + resolve({ cancelled: false }); + } else if (result.status === 'cancelled') { + clearInterval(checkElevation); + resolve({ cancelled: true }); } - }, 1000); + }, 300); // if the elevation didn't occured in 30 seconds we reject the promise setTimeout(() => { @@ -192,27 +87,7 @@ export async function sudo( reject(new Error('Elevation timeout')); }, 30000); }); - - // WindowsWaitForStatus(instance, end) - - // WindowsResult(instance, end) } catch (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; -} diff --git a/lib/util/api.ts b/lib/util/api.ts index 0fe1e402..bcf2c8ff 100644 --- a/lib/util/api.ts +++ b/lib/util/api.ts @@ -29,6 +29,28 @@ import { getSourceMetadata } from './source-metadata'; import type { DrivelistDrive } from '../shared/drive-constraints'; 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_PORT = process.env.ETCHER_SERVER_PORT as string; // const ETCHER_SERVER_ID = process.env.ETCHER_SERVER_ID as string; diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index a1df46c5..39f2c479 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -18,7 +18,7 @@ "drivelist": "^12.0.2", "electron-squirrel-startup": "^1.0.0", "electron-updater": "6.1.8", - "etcher-sdk": "9.1.2", + "etcher-sdk": "10.0.0", "i18next": "23.11.2", "immutable": "3.8.2", "lodash": "4.17.21", diff --git a/package.json b/package.json index 424cb89f..63eb44c6 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "drivelist": "^12.0.2", "electron-squirrel-startup": "^1.0.0", "electron-updater": "6.1.8", - "etcher-sdk": "9.1.2", + "etcher-sdk": "10.0.0", "i18next": "23.11.2", "immutable": "3.8.2", "lodash": "4.17.21", diff --git a/tests/shared/permissions.spec.ts b/tests/shared/permissions.spec.ts deleted file mode 100644 index e41c51aa..00000000 --- a/tests/shared/permissions.spec.ts +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright 2017 balena.io - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { expect } from 'chai'; -import * as os from 'os'; -import { stub } from 'sinon'; - -import * as permissions from '../../lib/shared/permissions'; - -describe('Shared: permissions', function () { - describe('.createLaunchScript()', function () { - describe('given windows', function () { - beforeEach(function () { - this.osPlatformStub = stub(os, 'platform'); - this.osPlatformStub.returns('win32'); - }); - - afterEach(function () { - this.osPlatformStub.restore(); - }); - - it('should escape environment variables and arguments', function () { - expect( - permissions.createLaunchScript( - 'C:\\Users\\Alice & Bob\'s Laptop\\"what"\\balenaEtcher', - ['"a Laser"', 'arg1', "'&/ ^ \\", '" $ % *'], - { - key: 'value', - key2: ' " \' ^ & = + $ % / \\', - key3: '8', - }, - ), - ).to.equal( - `chcp 65001${os.EOL}` + - `set "key=value"${os.EOL}` + - `set "key2= " ' ^ & = + $ % / \\"${os.EOL}` + - `set "key3=8"${os.EOL}` + - `"C:\\Users\\Alice & Bob's Laptop\\\\"what\\"\\balenaEtcher" "\\"a Laser\\"" "arg1" "'&/ ^ \\" "\\" $ % *"`, - ); - }); - }); - - for (const platform of ['linux', 'darwin']) { - describe(`given ${platform}`, function () { - beforeEach(function () { - this.osPlatformStub = stub(os, 'platform'); - this.osPlatformStub.returns(platform); - }); - - afterEach(function () { - this.osPlatformStub.restore(); - }); - - it('should escape environment variables and arguments', function () { - expect( - permissions.createLaunchScript( - '/home/Alice & Bob\'s Laptop/"what"/balenaEtcher', - ['arg1', "'&/ ^ \\", '" $ % *'], - { - key: 'value', - key2: ' " \' ^ & = + $ % / \\', - key3: '8', - }, - ), - ).to.equal( - `export key='value'${os.EOL}` + - `export key2=' " '\\'' ^ & = + $ % / \\'${os.EOL}` + - `export key3='8'${os.EOL}` + - `'/home/Alice & Bob'\\''s Laptop/"what"/balenaEtcher' 'arg1' ''\\''&/ ^ \\' '" $ % *'`, - ); - }); - }); - } - }); -});