Skip to content
10 changes: 6 additions & 4 deletions src/main/pythonenvdialog/pythonenvdialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
isEnvInstalledByDesktopApp,
launchTerminalInDirectory,
openDirectoryInExplorer,
shellQuotePath,
waitForDuration
} from '../utils';
import { EventManager } from '../eventmanager';
Expand Down Expand Up @@ -131,8 +132,7 @@ export class ManagePythonEnvironmentDialog {
const condaEnvPath = condaEnvPathForCondaExePath(condaPath);
const activateCommand = createCommandScriptInEnv(
envPath,
condaEnvPath,
{ quoteChar: "'" }
condaEnvPath
);

launchTerminalInDirectory({
Expand Down Expand Up @@ -168,8 +168,10 @@ export class ManagePythonEnvironmentDialog {
envPath,
condaEnvPath,
{
command: `jupyter lab --notebook-dir='${workingDir}'`,
quoteChar: "'",
command: `jupyter lab --notebook-dir=${shellQuotePath(
workingDir,
process.platform === 'win32'
)}`,
joinStr: ' && '
}
);
Expand Down
121 changes: 78 additions & 43 deletions src/main/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,8 +372,14 @@ export async function installCondaPackEnvironment(
});

let unpackCommand = isWin
? `${installPath}\\Scripts\\activate.bat && conda-unpack`
: `source "${installPath}/bin/activate" && conda-unpack`;
? `${shellQuotePath(
`${installPath}\\Scripts\\activate.bat`,
true
)} && conda-unpack`
: `source ${shellQuotePath(
`${installPath}/bin/activate`,
false
)} && conda-unpack`;

// only unsign when installing from bundled installer
if (platform === 'darwin' && isBundledInstaller) {
Expand Down Expand Up @@ -538,12 +544,18 @@ export function isBaseCondaEnv(envPath: string): boolean {
return fs.existsSync(condaBinPath) && fs.lstatSync(condaBinPath).isFile();
}

// POSIX single-quotes because double quotes still evaluate $(...) and backticks;
// the '\'' idiom closes, escapes and reopens. cmd has no substitution, so quotes
// there are about spaces (#837), and Windows paths cannot contain a quote.
export function shellQuotePath(value: string, isWin: boolean): string {
return isWin ? `"${value}"` : `'${value.split("'").join(`'\\''`)}'`;
}

export function createCommandScriptInEnv(
envPath: string,
baseCondaEnvPath: string,
options?: {
command?: string;
quoteChar?: string;
joinStr?: string;
}
): string {
Expand All @@ -556,7 +568,6 @@ export function createCommandScriptInEnv(
//
}

const quoteChar = options?.quoteChar || '"';
const joinStr = options?.joinStr || '\n';
let command = options?.command;
const isWin = process.platform === 'win32';
Expand All @@ -570,7 +581,7 @@ export function createCommandScriptInEnv(
// instead call using conda from the base environment with -p parameter
const isCondaCommand = isConda && command?.startsWith('conda ');
if (isCondaCommand && !isBaseCondaEnv(envPath)) {
command = `${command} -p ${envPath}`;
command = `${command} -p ${shellQuotePath(envPath, isWin)}`;
}

// conda activate is only available in base conda environments or
Expand All @@ -591,20 +602,22 @@ export function createCommandScriptInEnv(

const scriptLines: string[] = [];

const quote = (value: string) => shellQuotePath(value, isWin);

if (isWin) {
scriptLines.push(`CALL ${activatePath}`);
scriptLines.push(`CALL ${quote(activatePath)}`);
if (isConda && isBaseCondaActivate) {
scriptLines.push(`CALL conda activate ${envPath}`);
scriptLines.push(`CALL conda activate ${quote(envPath)}`);
}
if (command) {
scriptLines.push(`CALL ${command}`);
}
} else {
scriptLines.push(`source ${quoteChar}${activatePath}${quoteChar}`);
scriptLines.push(`source ${quote(activatePath)}`);
if (isConda && isBaseCondaActivate) {
scriptLines.push(`source ${quoteChar}${condaSourcePath}${quoteChar}`);
scriptLines.push(`source ${quote(condaSourcePath)}`);
if (!isCondaCommand) {
scriptLines.push(`conda activate ${quoteChar}${envPath}${quoteChar}`);
scriptLines.push(`conda activate ${quote(envPath)}`);
}
}
if (command) {
Expand Down Expand Up @@ -633,14 +646,15 @@ export function createUnsignScriptInEnv(envPath: string): string {

fileContents.split(/\r?\n/).forEach(line => {
if (line) {
signList.push(`"${line}"`);
signList.push(shellQuotePath(line, false));
}
});

// sign all binaries with ad-hoc signature
return `cd ${envPath} && codesign -s - -o 0x2 -f ${signList.join(
' '
)} && cd -`;
return `cd ${shellQuotePath(
envPath,
false
)} && codesign -s - -o 0x2 -f ${signList.join(' ')} && cd -`;
}

export function getLogFilePath(processType: 'main' | 'renderer' = 'main') {
Expand Down Expand Up @@ -709,7 +723,9 @@ export function openDirectoryInExplorer(dirPath: string): boolean {
? 'explorer'
: 'xdg-open';

exec(`${openCommand} "${dirPath}"`);
// execFile, not exec: passing argv directly means no shell parses dirPath,
// so quoting and substitution never come up.
execFile(openCommand, [dirPath]);

return true;
}
Expand All @@ -728,24 +744,29 @@ export function launchTerminalInDirectory(options: {
let commands = options.commands;

if (platform === 'darwin') {
let callCommands = '';
if (commands) {
// replace " with '
commands = commands.split('"').join("'");
callCommands = `&& ${commands}`;
}

exec(
`osascript -e 'tell application "Terminal" to do script "cd '${dirPath}' ${callCommands}"' -e 'tell application "Terminal" to activate'`
);
// Build the shell line first, then hand osascript its argv directly. Going
// through exec() would let /bin/sh parse this string too, and that outer
// pass expands $(...) in the paths before Terminal ever sees them.
const shellLine = commands
? `cd ${shellQuotePath(dirPath, false)} && ${commands}`
: `cd ${shellQuotePath(dirPath, false)}`;
// AppleScript string literal: only \ and " need escaping.
const asString = shellLine.split('\\').join('\\\\').split('"').join('\\"');

execFile('osascript', [
'-e',
`tell application "Terminal" to do script "${asString}"`,
'-e',
'tell application "Terminal" to activate'
]);
} else if (platform === 'win32') {
if (commands) {
const activateFilePath = createTempFile(
`activate.bat`,
`cd /D "${dirPath}"\n${commands}`
`cd /D ${shellQuotePath(dirPath, true)}\n${commands}`
);

exec(`start cmd.exe /K ${activateFilePath}`);
execFile('cmd', ['/c', 'start', 'cmd.exe', '/K', activateFilePath]);

setTimeout(() => {
try {
Expand All @@ -755,18 +776,21 @@ export function launchTerminalInDirectory(options: {
}
}, 2000);
} else {
exec(`start cmd.exe /K cd /D "${dirPath}"`);
execFile('cmd', ['/c', 'start', 'cmd.exe', '/K', 'cd', '/D', dirPath]);
}
} else {
let callCommands = '';
const args = [`--working-directory=${dirPath}`];
if (commands) {
// note that calling "exec bash" at the end will cause .bashrc to be reloaded,
// which could possibly override python path (e.g. base conda initialization)
callCommands = ` -- bash -c "${commands}${
interactive ? '; exec bash' : ''
}"`;
// "exec bash" at the end reloads .bashrc, which could override the python
// path (e.g. base conda initialization)
args.push(
'--',
'bash',
'-c',
`${commands}${interactive ? '; exec bash' : ''}`
);
}
exec(`gnome-terminal --working-directory="${dirPath}"${callCommands}`);
execFile('gnome-terminal', args);
}
}
Comment thread
Copilot marked this conversation as resolved.

Expand Down Expand Up @@ -827,24 +851,35 @@ export async function setupJlabCLICommandWithElevatedRights(): Promise<
const shellCommands: string[] = [];
const symlinkParentDir = path.dirname(symlinkPath);

// this runs as root, so quote every path rather than relying on them being tame
const q = (p: string) => shellQuotePath(p, false);

// create parent directory
if (!fs.existsSync(symlinkParentDir)) {
shellCommands.push(`mkdir -p ${symlinkParentDir}`);
shellCommands.push(`mkdir -p ${q(symlinkParentDir)}`);
}

// create symlink
shellCommands.push(`ln -f -s \\"${targetPath}\\" \\"${symlinkPath}\\"`);
shellCommands.push(`ln -f -s ${q(targetPath)} ${q(symlinkPath)}`);

// make files executable
shellCommands.push(`chmod 755 \\"${symlinkPath}\\"`);
shellCommands.push(`chmod 755 \\"${targetPath}\\"`);
shellCommands.push(`chmod 755 ${q(symlinkPath)}`);
shellCommands.push(`chmod 755 ${q(targetPath)}`);

const command = `do shell script "${shellCommands.join(
' && '
)}" with administrator privileges`;
// AppleScript string literal: escape \ and " only
const asString = shellCommands
.join(' && ')
.split('\\')
.join('\\\\')
.split('"')
.join('\\"');

return new Promise<boolean>((resolve, reject) => {
const cliSetupProc = exec(`osascript -e '${command}'`);
// execFile, so /bin/sh never gets a chance to parse the composed command
const cliSetupProc = execFile('osascript', [
'-e',
`do shell script "${asString}" with administrator privileges`
]);

cliSetupProc.on('exit', (exitCode: number) => {
if (exitCode === 0) {
Expand Down
Loading
Loading