update
This commit is contained in:
209
src/main/interpreter.ts
Normal file
209
src/main/interpreter.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
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.ts:index.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 段,每段里再串行 tryValidate,20+ 候选在慢盘上能到 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
|
||||
}
|
||||
Reference in New Issue
Block a user