210 lines
8.5 KiB
TypeScript
210 lines
8.5 KiB
TypeScript
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
|
||
}
|