Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 12 additions & 9 deletions src/providers/python_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,25 +101,28 @@ export default class Python_controller {

prepareEnvironment() {
if(!this.realEnvironment) {
this.pythonEnvDir = path.join(path.sep, "tmp", "trustify_da_env_js")
const isWindows = os.platform() === 'win32'
// Windows hosts keep the venv in the OS-configured temp directory so it is
// writable without elevation and independent of the current drive. POSIX
// hosts keep the existing fixed /tmp location.
// Match path semantics to the detected platform so Windows paths stay
// drive-aware even when the Windows branch runs on another host (tests).
const venvPath = isWindows ? path.win32 : path.posix
this.pythonEnvDir = isWindows
? venvPath.join(os.tmpdir(), "trustify_da_env_js")
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Resolved
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Resolved
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Resolved
: venvPath.join("/", "tmp", "trustify_da_env_js")
try {
invokeCommand(this.pathToPythonBin, ['-m', 'venv', this.pythonEnvDir])
} catch (error) {
throw new Error('Failed creating virtual python environment', {cause: error})
}
const isWindows = os.platform() === 'win32'
// venv always installs the unversioned `python` and `pip` names regardless of
// how the source interpreter is named: `Scripts\python.exe`/`Scripts\pip.exe`
// on Windows and `bin/python`/`bin/pip` on POSIX. A `python3` source must not
// be mapped to the nonexistent `Scripts\python3.exe` (TC-7485).
const envBinDir = isWindows ? "Scripts" : "bin"
this.pathToPipBin = path.join(path.sep, this.pythonEnvDir, envBinDir, this.#decideIfWindowsOrLinuxPath("pip"))
this.pathToPythonBin = path.join(path.sep, this.pythonEnvDir, envBinDir, this.#decideIfWindowsOrLinuxPath("python"))
if(isWindows) {
let driveLetter = path.parse(process.cwd()).root
this.pathToPythonBin = `${driveLetter}${this.pathToPythonBin.substring(1)}`
this.pathToPipBin = `${driveLetter}${this.pathToPipBin.substring(1)}`
}
this.pathToPipBin = venvPath.join(this.pythonEnvDir, envBinDir, this.#decideIfWindowsOrLinuxPath("pip"))
this.pathToPythonBin = venvPath.join(this.pythonEnvDir, envBinDir, this.#decideIfWindowsOrLinuxPath("python"))
// upgrade pip version to latest
try {
invokeCommand(this.pathToPythonBin, ['-m', 'pip', 'install', '--upgrade', 'pip'])
Expand Down
73 changes: 66 additions & 7 deletions src/tools.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { execFileSync } from "child_process";
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { EOL } from "os";

import { HttpsProxyAgent } from "https-proxy-agent";
Expand Down Expand Up @@ -236,14 +237,23 @@ export function resolveBinary(globalBinary, localWrapper, startDir, opts = {}) {
* @returns {string}
*/
export function invokeCommand(bin, args, opts={}) {
// .bat and .cmd files can't be executed in windows with execFileSync without {shell: true}, so we
// special case them here to keep the amount of escaping we need to do to a minimum.
// https://nodejs.org/docs/latest-v20.x/api/child_process.html#spawning-bat-and-cmd-files-on-windows
// https://github.com/nodejs/node/issues/52681#issuecomment-2076426887
if (process.platform === 'win32') {
opts = {...opts, shell: true}
args = args.map(arg => handleSpacesInPath(arg))
bin = handleSpacesInPath(bin)
bin = resolveWindowsCommand(bin, opts.cwd)
// Native executables take literal argv values; never pre-quote them or enable a shell.
opts = { ...opts, shell: false, windowsVerbatimArguments: false }
if (/\.(?:bat|cmd)$/i.test(bin)) {
// Batch files require cmd.exe. Escape its command string ourselves instead of
// passing an unescaped argument array with shell:true (Node does not escape it).
// npm .bin shims reparse arguments, requiring a second layer of caret escaping.
const isShim = /node_modules[\\/]\.bin[\\/][^\\/]+\.cmd$/i.test(bin)
const command = [
escapeWindowsShellToken(path.win32.normalize(bin)),
...args.map(arg => escapeWindowsShellToken(arg, true, isShim))
].join(' ')
bin = process.env.ComSpec || 'cmd.exe'
args = ['/d', '/v:off', '/s', '/c', `"${command}"`]
opts = { ...opts, windowsVerbatimArguments: true }
}
}

opts = {
Expand All @@ -264,6 +274,55 @@ export function invokeCommand(bin, args, opts={}) {
return execFileSync(bin, args, {...{stdio: 'pipe', encoding: 'utf-8'}, ...opts})
}

/** Resolves Windows commands before classifying PATH batch wrappers and local npm shims. */
function resolveWindowsCommand(bin, cwd) {
const hasExtension = /\.(?:exe|com|bat|cmd)$/i.test(bin)
if (/\.(?:exe|com)$/i.test(bin) || (hasExtension && path.win32.isAbsolute(bin))) {
return bin
}
cwd = cwd instanceof URL ? fileURLToPath(cwd) : cwd || process.cwd()
const directories = /[\\/]/.test(bin) ? [''] : ['', ...(process.env.PATH || '').split(';')]
const extensions = hasExtension
? ['']
: (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)
for (const directory of directories) {
for (const extension of extensions) {
const candidate = path.win32.resolve(cwd, directory.replace(/^"|"$/g, ''), bin + extension)
if (fs.statSync(candidate, { throwIfNoEntry: false })?.isFile()) {
return candidate
}
}
}
return bin
}

/** Quotes Windows argv values and escapes cmd syntax, including percent and bang expansion. */
function escapeWindowsShellToken(value, argument = false, doubleEscape = false) {
if (/[\r\n]/.test(value)) {
throw new Error('Windows batch command inputs must not contain line breaks')
}
if (argument) {
// Windows argv quoting: double backslashes before quotes and the closing quote.
// A single pass avoids regex backtracking on long runs of backslashes.
let quoted = '"'
let backslashes = 0
for (const character of value) {
if (character === '\\') {
backslashes++
continue
}
quoted += '\\'.repeat(character === '"' ? backslashes * 2 + 1 : backslashes) + character
backslashes = 0
}
value = quoted + '\\'.repeat(backslashes * 2) + '"'
}
// cmd metacharacters must be escaped even inside quotes: quoting alone permits expansion.
// This is the cmd token-escaping strategy also used by cross-spawn.
const metacharacters = /([()\][%!^"`<>&|;, *?])/g
value = value.replace(metacharacters, '^$1')
return doubleEscape ? value.replace(metacharacters, '^$1') : value
}


export const TRUSTIFY_DA_TOKEN_HEADER = "trust-da-token";
export const TRUSTIFY_DA_TELEMETRY_ID_HEADER = "telemetry-anonymous-id";
Expand Down
78 changes: 50 additions & 28 deletions test/providers/python_controller.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,24 @@ import {
const REQUIREMENTS_MANIFEST = 'test/providers/tst_manifests/pip/pip_requirements_txt_no_ignore/requirements.txt'

/**
* Loads Python_controller with os.platform() and command invocation stubbed, then
* constructs a virtual-environment controller so the venv setup invocations can be
* inspected. Stubbing the parser keeps the test independent of the tree-sitter wasm
* artifacts.
* Loads Python_controller with os.platform(), os.tmpdir() and command invocation
* stubbed, then constructs a virtual-environment controller so the venv setup
* invocations can be inspected. Stubbing the parser keeps the test independent of
* the tree-sitter wasm artifacts.
* @param {string} platform - platform reported by os.platform()
* @param {string} sourcePython - configured source python binary
* @param {string} tempDir - directory reported by os.tmpdir()
* @return {Promise<{controller: Python_controller, invocations: Array<{bin: string, args: string[]}>}>}
*/
async function constructVirtualEnvController(platform, sourcePython) {
async function constructVirtualEnvController(platform, sourcePython, tempDir = os.tmpdir()) {
const invocations = []
/** Records a command invocation instead of executing it. */
const recordInvocation = (bin, args) => {
invocations.push({ bin, args })
return ''
}
const { default: MockedPythonController } = await esmock('../../src/providers/python_controller.js', {
os: { default: { platform: () => platform }, EOL: '\n' },
os: { default: { platform: () => platform, tmpdir: () => tempDir }, EOL: '\n' },
'../../src/tools.js': {
environmentVariableIsPopulated: () => false,
getCustom: () => undefined,
Expand Down Expand Up @@ -118,12 +119,15 @@ suite('Python_controller BEST_EFFORTS validation', function() {
})

suite('Python_controller virtual environment executables', function() {
const windowsTempDir = 'C:\\Users\\da-test\\AppData\\Local\\Temp'
const windowsVenvDir = path.win32.join(windowsTempDir, 'trustify_da_env_js')

// Windows venv executable names must not depend on how the source interpreter is named (TC-7485).
for (const sourcePython of ['python3', 'python']) {
/** Verifies that a Windows venv created from `sourcePython` upgrades pip via Scripts/python.exe. */
test(`upgrades pip via Scripts/python.exe on Windows for a ${sourcePython} source`, async function() {
// Given a Windows host and a configured source interpreter named sourcePython
const { controller, invocations } = await constructVirtualEnvController('win32', sourcePython)
// Given a Windows host with a configured temp directory and a source interpreter named sourcePython
const { controller, invocations } = await constructVirtualEnvController('win32', sourcePython, windowsTempDir)

// When the venv is prepared while the controller is constructed
const venvInvocation = invocations[0]
Expand All @@ -132,27 +136,18 @@ suite('Python_controller virtual environment executables', function() {
// Then the venv is created from the configured source interpreter
expect(invocations).to.have.lengthOf(2)
expect(venvInvocation.bin).to.equal(sourcePython)
expect(venvInvocation.args).to.deep.equal(['-m', 'venv', path.join(path.sep, 'tmp', 'trustify_da_env_js')])

// Then the executable paths are anchored to the venv directory that was created.
// Resolving the recorded target makes this match the controller's drive-prefixed
// Windows paths on a native host as well as under the mocked win32 platform.
const expectedVenvDir = path.resolve(venvInvocation.args[2])
expect(path.resolve(controller.pythonEnvDir)).to.equal(expectedVenvDir)
const expectedPythonBin = path.join(expectedVenvDir, 'Scripts', 'python.exe')
const expectedPipBin = path.join(expectedVenvDir, 'Scripts', 'pip.exe')

// Then the pip upgrade runs through that venv's Scripts/python.exe exactly
const expectedPythonBin = path.win32.join(windowsVenvDir, 'Scripts', 'python.exe')
expect(pipUpgradeInvocation.args).to.deep.equal(['-m', 'pip', 'install', '--upgrade', 'pip'])
expect(pipUpgradeInvocation.bin).to.equal(expectedPythonBin)
expect(controller.pathToPythonBin).to.equal(expectedPythonBin)

// Then the venv pip path is exactly that venv's Scripts/pip.exe
expect(controller.pathToPipBin).to.equal(expectedPipBin)
expect(controller.pathToPipBin).to.equal(path.win32.join(windowsVenvDir, 'Scripts', 'pip.exe'))

// Then no invocation targets the nonexistent Scripts/python3.exe
expect(path.basename(pipUpgradeInvocation.bin)).to.not.equal('python3.exe')
expect(invocations.map(invocation => path.basename(invocation.bin))).to.not.include('python3.exe')
expect(invocations.map(invocation => path.basename(invocation.bin))).to.not.include('pip3.exe')
})
}
Expand All @@ -162,8 +157,8 @@ suite('Python_controller virtual environment executables', function() {
for (const sourcePython of ['python3', 'python']) {
/** Verifies that POSIX venvs expose bin/python and bin/pip regardless of the source interpreter name. */
test(`uses bin/python and bin/pip on non-Windows hosts for a ${sourcePython} source`, async function() {
// Given a POSIX host and a configured source interpreter named sourcePython
const { controller, invocations } = await constructVirtualEnvController('linux', sourcePython)
// Given a POSIX host whose configured temp directory is not /tmp
const { controller, invocations } = await constructVirtualEnvController('linux', sourcePython, '/var/tmp')

// When the venv is prepared while the controller is constructed
const venvInvocation = invocations[0]
Expand All @@ -172,18 +167,45 @@ suite('Python_controller virtual environment executables', function() {
// Then the venv is created from the configured source interpreter
expect(invocations).to.have.lengthOf(2)
expect(venvInvocation.bin).to.equal(sourcePython)
expect(venvInvocation.args[2]).to.equal(path.join(path.sep, 'tmp', 'trustify_da_env_js'))

// Then the pip upgrade runs through the venv bin/python exactly
// Then the venv still uses the fixed /tmp location with bin/python and bin/pip
expect(venvInvocation.args[2]).to.equal('/tmp/trustify_da_env_js')
expect(pipUpgradeInvocation.args).to.deep.equal(['-m', 'pip', 'install', '--upgrade', 'pip'])
expect(pipUpgradeInvocation.bin).to.equal(path.join(venvInvocation.args[2], 'bin', 'python'))
expect(controller.pathToPythonBin).to.equal(path.join(venvInvocation.args[2], 'bin', 'python'))

// Then the venv pip path is bin/pip
expect(controller.pathToPipBin).to.equal(path.join(venvInvocation.args[2], 'bin', 'pip'))
expect(pipUpgradeInvocation.bin).to.equal('/tmp/trustify_da_env_js/bin/python')
expect(controller.pathToPythonBin).to.equal('/tmp/trustify_da_env_js/bin/python')
expect(controller.pathToPipBin).to.equal('/tmp/trustify_da_env_js/bin/pip')
})
}
})

suite('Python_controller virtual environment placement', function() {
/**
* Verifies that Windows venvs are created and upgraded under the OS-configured temp
* directory instead of a root-relative \tmp path on the current drive.
*/
test('creates and upgrades the Windows venv under the configured temp directory', async function() {
// Given Windows configured D:\Temp on the D: drive
const tempDir = 'D:\\Temp'
const { controller, invocations } = await constructVirtualEnvController('win32', 'python', tempDir)

// When the virtual environment is prepared on controller construction
const venvInvocation = invocations[0]
const pipUpgradeInvocation = invocations[1]
const expectedVenvDir = path.win32.join(tempDir, 'trustify_da_env_js')

// Then the venv is created in the configured temp directory, on its drive
expect(venvInvocation.args).to.deep.equal(['-m', 'venv', expectedVenvDir])
expect(controller.pythonEnvDir).to.equal(expectedVenvDir)
expect(path.win32.parse(controller.pythonEnvDir).root).to.equal('D:\\')

// Then the old current-drive-root \tmp location is not used
expect(controller.pythonEnvDir).to.not.equal('C:\\tmp\\trustify_da_env_js')

// Then the pip upgrade runs from the same venv's Scripts directory
expect(pipUpgradeInvocation.bin).to.equal(path.win32.join(expectedVenvDir, 'Scripts', 'python.exe'))
expect(controller.pathToPipBin).to.equal(path.win32.join(expectedVenvDir, 'Scripts', 'pip.exe'))
})
})
suite('Python_controller requirements file decoding', () => {
const requirementsText = 'blinker==1.9.0\r\ncertifi==2026.10.23\r\n'
let tempDir
Expand Down
Loading
Loading