Files
Python-Profiler-Visualizer/src/main/interpreter.ts
2026-09-12 14:19:56 +08:00

210 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { execFile } from 'child_process'
import { promisify } from 'util'
import { existsSync, readdirSync, statSync } from 'fs'
import { join } from 'path'
import { homedir } from 'os'
import type { InterpreterInfo } from '../shared/analysis'
const execFileAsync = promisify(execFile)
// 一行探测脚本:输出 JSON {version}
const PROBE = 'import sys,json;' + 'print(json.dumps({"version":"%d.%d.%d"%sys.version_info[:3]}))'
// 候选可执行名PATH 中)
const CANDIDATES =
process.platform === 'win32' ? ['python', 'py', 'python3', 'python.exe'] : ['python3', 'python']
// 解释器白名单:只接受"看起来像 python"且**绝对路径**的可执行文件。
// 之前 basename 检查 (`python.exe` / python3) 接受任何匹配名,包括光 `"python"`
// 这种裸名 — spawn 会按 PATH 解析,攻击者把恶意的 `python` 放进靠前 PATH 就能劫持。
//
// 放在 interpreter.ts 而不是 index.tsindex.ts 导入 electron单元测试里没法直接
// require这个函数是纯字符串判断挪过来就能被 interpreter.test.ts 直接覆盖。
export function isPythonLikePath(p: string): boolean {
if (typeof p !== 'string' || !p) return false
// 绝对路径Windows (C:\ / D:\ / \\server\) 或 POSIX (/...)
const isAbsolute =
process.platform === 'win32' ? /^[a-zA-Z]:[\\/]/.test(p) || p.startsWith('\\\\') : p.startsWith('/')
if (!isAbsolute) return false
// 拒绝 ../ 段穿透 — normalize 后还含 .. 就视为不安全
const normalized = p.replace(/\\/g, '/')
if (/\.\.[/\\]/.test(normalized) || normalized.endsWith('/..')) return false
const name = p.split(/[\\/]/).pop()?.toLowerCase() ?? ''
// 允许带小版本号macOS/Linux 上 /usr/bin/python3.11、python3.12 是系统 Python 的
// 常见形态。之前的 /^python(\d*)?(\.exe)?$/ 只匹配 python / python3把这些全拒了
// 用户被迫做符号链接才能用。
return /^python(\d+(\.\d+)*)?(\.exe)?$/.test(name)
}
/** 在常见安装路径下补全候选可执行文件venv、conda、pyenv、uv、官方安装器 */
function extraCandidates(): string[] {
const out: string[] = []
const home = homedir()
if (process.platform === 'win32') {
const local = process.env['LOCALAPPDATA']
const programFiles = process.env['ProgramFiles'] || 'C:\\Program Files'
if (local) {
// Windows 官方安装器默认路径
for (let i = 0; i < 5; i++) {
out.push(join(local, 'Programs', 'Python', `Python3${i}`, 'python.exe'))
out.push(join(local, 'Programs', 'Python', `Python3${i}-32`, 'python.exe'))
}
}
out.push(join(programFiles, 'Python313', 'python.exe'))
out.push(join(programFiles, 'Python312', 'python.exe'))
out.push(join(programFiles, 'Python311', 'python.exe'))
out.push(join(programFiles, 'Python310', 'python.exe'))
} else {
// Linux/macOS 常见路径。pyenv 的 .pyenv/versions 是目录,会在 step 3 展开处理;
// 这里不再尝试直接 validate 整个目录(之前会被 `path.includes('*')` 的"占位"逻辑误判)
out.push('/usr/bin/python3')
out.push('/usr/local/bin/python3')
out.push('/opt/homebrew/bin/python3')
if (home) {
out.push(join(home, '.local', 'bin', 'python3'))
}
}
return out
}
/** 列出 conda 环境中的 python 路径(尽力而为,失败静默) */
async function listCondaPythons(): Promise<string[]> {
try {
const { stdout } = await execFileAsync('conda', ['env', 'list', '--json'], { timeout: 5000 })
const data: unknown = JSON.parse(stdout)
const envs: string[] = Array.isArray((data as { envs?: unknown[] } | null)?.envs)
? (data as { envs: unknown[] }).envs.filter((p): p is string => typeof p === 'string')
: []
const out: string[] = []
for (const env of envs) {
const py = process.platform === 'win32' ? join(env, 'python.exe') : join(env, 'bin', 'python')
if (existsSync(py)) out.push(py)
}
return out
} catch {
return []
}
}
/** 列出 `py -0p` 给出的所有 Windows Python Launcher 安装 */
async function listPyLauncher(): Promise<string[]> {
if (process.platform !== 'win32') return []
try {
const { stdout } = await execFileAsync('py', ['-0p'], { timeout: 5000 })
return parsePyLauncherOutput(stdout)
} catch {
return []
}
}
/**
* 把 `py -0p` 的 stdout 解析成候选 python.exe 路径。
* 每行格式: ` -V:3.X [*] PATH` 或 ` -V:3.X [*] "PATH with spaces"`
* 之前取行内最后一个 token 再过滤 .exe 后缀 — 但某些环境下 launcher 会在末尾追加注释/环境标记,
* 导致行末 token 不是路径,那条安装就被漏掉。
* 改成"扫所有 token取最后一个以 .exe / .EXE 结尾的"——更稳,且兼容原有所有合法行。
*/
export function parsePyLauncherOutput(stdout: string): string[] {
const out: string[] = []
const tokenRe = /"([^"]*)"|(\S+)/g
for (const line of stdout.split(/\r?\n/)) {
let last: string | undefined
let m: RegExpExecArray | null
tokenRe.lastIndex = 0
while ((m = tokenRe.exec(line)) !== null) {
const tok = m[1] !== undefined ? m[1] : m[2]
if (/\.(exe|EXE)$/.test(tok)) last = tok
}
if (last) out.push(last)
}
return out
}
export async function validateInterpreter(path: string): Promise<InterpreterInfo> {
const { stdout } = await execFileAsync(path, ['-c', PROBE], { timeout: 8000 })
// 不直接 JSON.parse(stdout.trim()):第三方包可能在 stdout 印警告deprecation banner 等),
// 那样就把本来能用的解释器当失败丢掉了。取最后一个 '{' 开头的 token 解 JSON。
const lines = stdout.split(/\r?\n/)
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i].trim()
if (line.startsWith('{')) {
const data: unknown = JSON.parse(line)
// 校验形状:避免 {version: undefined} 这种"看起来成功"的假阳性
// 导致 UI 显示 "Python undefined"。有 PYTHONSTARTUP / 旧 Python 警告时可能踩到。
const probe = data as { version?: unknown } | null
if (typeof probe?.version !== 'string' || !/^\d+\.\d+\.\d+/.test(probe.version)) {
throw new Error(`probe 返回的 JSON 缺少有效 version 字段: ${line.slice(0, 120)}`)
}
return { path, version: probe.version }
}
}
throw new Error('interpreter probe returned no JSON object')
}
async function tryValidate(path: string): Promise<InterpreterInfo | null> {
if (!path) return null
// pyenv 版本目录占位(前缀如 /home/.../.pyenv/versions 本身)— 不是可执行文件
if (path.includes('*')) return null
if (!existsSync(path)) return null
try {
return await validateInterpreter(path)
} catch {
return null
}
}
/** 列出一个 phase 的所有候选(不做 validate路径解析 / 文件系统访问)。 */
function pyenvCandidates(): string[] {
const home = homedir()
if (!home) return []
const pyenvRoot = join(home, '.pyenv', 'versions')
if (!existsSync(pyenvRoot)) return []
try {
const out: string[] = []
for (const ver of readdirSync(pyenvRoot)) {
const bin = join(pyenvRoot, ver, process.platform === 'win32' ? 'python.exe' : 'bin/python')
if (existsSync(bin) && statSync(bin).isFile()) out.push(bin)
}
return out
} catch {
return []
}
}
/** 并行 validate 一组候选;返回成功的去重后列表(不去重由调用方做)。 */
async function validateAll(paths: string[]): Promise<InterpreterInfo[]> {
const results = await Promise.all(paths.map((p) => tryValidate(p)))
const out: InterpreterInfo[] = []
for (const r of results) if (r) out.push(r)
return out
}
export async function detectInterpreters(): Promise<InterpreterInfo[]> {
const found: InterpreterInfo[] = []
const seen = new Set<string>()
const add = (info: InterpreterInfo) => {
const key = info.path
if (seen.has(key)) return
seen.add(key)
found.push(info)
}
// 五个 phase 并行跑,每个 phase 内部也并行 validate。
// 之前是串行 5 段,每段里再串行 tryValidate20+ 候选在慢盘上能到 4s。
// 并行后总耗时 = max(单 phase 耗时),通常 < 1s。
const [phasePath, phaseExtra, phasePyenv, phaseLauncher, phaseConda] = await Promise.all([
validateAll(CANDIDATES),
validateAll(extraCandidates()),
validateAll(pyenvCandidates()),
listPyLauncher().then(validateAll),
listCondaPythons().then(validateAll)
])
for (const list of [phasePath, phaseExtra, phasePyenv, phaseLauncher, phaseConda]) {
for (const info of list) add(info)
}
return found
}