Add plugin ide-open v1.0.0#280
Conversation
- feat: ide-open plugin for ZTools — manage recent projects across VSCode/JetBrains editors - docs: rewrite README for ide-open plugin
There was a problem hiding this comment.
Code Review
This pull request introduces the ide-open plugin for ZTools, allowing users to manage and quickly open recent projects from various VS Code and JetBrains editors. The implementation includes a React-based frontend and a Node.js preload script for reading local databases, JSON, and XML files. Feedback on the changes highlights several critical security and robustness issues in the preload services, including a command injection vulnerability in openProject, fragile XML parsing prone to ReDoS, potential file corruption during database updates, and unhandled URI decoding exceptions. Additionally, improvements were suggested to sort version directories for JetBrains editors, use standard fileURLToPath for path conversion, and remove redundant feature registration logic in the settings page.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| function openProject(command, uri, shell) { | ||
| const effectiveShell = shell || defaultShell | ||
| const isRemote = /^[a-z]+-remote:\/\//.test(uri) | ||
| const localPath = isRemote ? '' : uriToPath(uri) | ||
|
|
||
| const run = (cmd, timeout = 10000) => new Promise((resolve, reject) => { | ||
| const fullCmd = effectiveShell ? `${effectiveShell} '${cmd}'` : cmd | ||
| debugLog(`执行: ${fullCmd}`) | ||
| exec(fullCmd, { env: process.env, windowsHide: true, timeout }, (err) => { | ||
| if (err) { | ||
| debugLog(`命令失败: ${err.message}`) | ||
| reject(err) | ||
| } else resolve() | ||
| }) | ||
| }) | ||
|
|
||
| return new Promise((resolve, reject) => { | ||
| if (localPath) { | ||
| run(`${command} "${localPath}"`) | ||
| .then(() => resolve()) | ||
| .catch(err => reject(new Error(`启动失败: ${err.message}`))) | ||
| return | ||
| } | ||
| const isWorkspace = uri.endsWith('.code-workspace') | ||
| const flag = isWorkspace ? '--file-uri' : '--folder-uri' | ||
| run(`${command} ${flag} "${uri}"`) | ||
| .then(() => resolve()) | ||
| .catch(err => reject(new Error(`启动失败: ${err.message}`))) | ||
| }) | ||
| } |
There was a problem hiding this comment.
安全风险:命令注入漏洞
在 openProject 函数中,启动命令和项目路径(localPath 或 uri)直接通过字符串插值拼接到 shell 命令中执行。如果项目路径或 URI 中包含单引号、双引号或 shell 元字符(如 ;, &, |, $(), 反引号等),将会导致命令注入漏洞,允许执行任意系统命令。
改进建议:
在拼接命令前,对参数进行严格的 shell 转义。对于 macOS/Linux,使用单引号包裹并转义内部的单引号;对于 Windows,使用双引号包裹并转义内部的双引号。这样可以确保路径中的特殊字符被安全地视为字面量,防止命令注入。
function openProject(command, uri, shell) {
const effectiveShell = shell || defaultShell
const isRemote = /^[a-z]+-remote:\/\//.test(uri)
const localPath = isRemote ? '' : uriToPath(uri)
const escapeShellArg = (arg) => {
if (process.platform === 'win32') {
return `"${arg.replace(/"/g, '""')}"`
}
return `'${arg.replace(/'/g, "'\\''")}'`
}
const run = (cmd, timeout = 10000) => new Promise((resolve, reject) => {
const fullCmd = effectiveShell ? `${effectiveShell} ${escapeShellArg(cmd)}` : cmd
debugLog(`执行: ${fullCmd}`)
exec(fullCmd, { env: process.env, windowsHide: true, timeout }, (err) => {
if (err) {
debugLog(`命令失败: ${err.message}`)
reject(err)
} else resolve()
})
})
return new Promise((resolve, reject) => {
if (localPath) {
run(`${command} ${escapeShellArg(localPath)}`)
.then(() => resolve())
.catch(err => reject(new Error(`启动失败: ${err.message}`)))
return
}
const isWorkspace = uri.endsWith('.code-workspace')
const flag = isWorkspace ? '--file-uri' : '--folder-uri'
run(`${command} ${flag} ${escapeShellArg(uri)}`)
.then(() => resolve())
.catch(err => reject(new Error(`启动失败: ${err.message}`)))
})
}| const nameByPath = {} | ||
| const entryRe = /<entry\s+key="([^"]+)"[^>]*>[\s\S]*?<option\s+name="projectName"\s+value="([^"]+)"\s*\/>[\s\S]*?<\/entry>/g | ||
| let n | ||
| while ((n = entryRe.exec(content)) !== null) { | ||
| nameByPath[n[1]] = n[2] | ||
| } |
There was a problem hiding this comment.
代码健壮性与性能:避免使用复杂的正则表达式解析 XML
使用 [\s\S]*? 的正则表达式解析 XML 结构非常脆弱。如果某些 <entry> 标签中没有 projectName 选项,该正则会跨越多个 <entry> 标签进行匹配,导致解析出的项目名称与路径错配。此外,在处理大型 XML 文件时,这种懒惰匹配可能会导致灾难性回溯(ReDoS),影响性能。
改进建议:
先将 XML 内容按 <entry 标签分割,然后对每个 entry 块分别提取 key 和 projectName。这样既简单、高效,又完全避免了跨标签错配和 ReDoS 风险。
const nameByPath = {}
const entries = content.split('<entry')
for (const entry of entries) {
const keyMatch = /key="([^"]+)"/.exec(entry)
const nameMatch = /<option\s+name="projectName"\s+value="([^"]+)"/.exec(entry)
if (keyMatch && nameMatch) {
nameByPath[keyMatch[1]] = nameMatch[1]
}
}| const updated = JSON.stringify(data) | ||
| db.run(`UPDATE ItemTable SET value = ? WHERE key = '${key}'`, [updated]) | ||
| const out = db.export() | ||
| fs.writeFileSync(filePath, Buffer.from(out)) | ||
| return |
There was a problem hiding this comment.
可靠性:使用原子写入防止文件损坏
直接使用 fs.writeFileSync(filePath, ...) 覆盖原始数据库文件。如果写入过程中发生异常、程序崩溃或断电,可能会导致 VS Code 的最近项目数据库文件损坏,丢失用户的历史记录。此外,db.run 中的 key 应该使用参数化查询以提高安全性。
改进建议:
- 使用参数化查询
UPDATE ItemTable SET value = ? WHERE key = ?。 - 先将新数据写入临时文件(如
filePath.tmp),然后通过fs.renameSync原子地替换原文件。这样可以确保即使写入失败,原文件也完好无损。
const updated = JSON.stringify(data)
db.run('UPDATE ItemTable SET value = ? WHERE key = ?', [updated, key])
const out = db.export()
const tempPath = filePath + '.tmp'
fs.writeFileSync(tempPath, Buffer.from(out))
fs.renameSync(tempPath, filePath)
return| const entries = fs.readdirSync(dir) | ||
| for (const entry of entries) { | ||
| if (entry.startsWith(prefix)) { | ||
| const resolved = path.join(dir, entry) + after | ||
| if (fs.existsSync(resolved)) return resolved | ||
| } | ||
| } |
There was a problem hiding this comment.
正确性:对版本目录进行排序以确保匹配最新版本
fs.readdirSync(dir) 返回的目录项顺序是无序的(取决于文件系统)。如果用户安装了多个版本的 JetBrains 编辑器(例如 IntelliJIdea2023.1 和 IntelliJIdea2023.3),直接遍历可能会匹配到旧版本的路径,而不是用户当前使用的最新版本。
改进建议:
在遍历前对目录项进行排序并反转(sort().reverse()),这样可以优先匹配到版本号更高的目录,确保读取到最新的项目历史记录。
| const entries = fs.readdirSync(dir) | |
| for (const entry of entries) { | |
| if (entry.startsWith(prefix)) { | |
| const resolved = path.join(dir, entry) + after | |
| if (fs.existsSync(resolved)) return resolved | |
| } | |
| } | |
| const entries = fs.readdirSync(dir).sort().reverse() | |
| for (const entry of entries) { | |
| if (entry.startsWith(prefix)) { | |
| const resolved = path.join(dir, entry) + after | |
| if (fs.existsSync(resolved)) return resolved | |
| } | |
| } |
| function parseEntries(entries) { | ||
| debugLog(`parseEntries 输入: ${entries.length} 条`) | ||
| const result = entries | ||
| .map(e => { | ||
| if (e == null) return null | ||
| if (typeof e === 'string') { | ||
| if (!e) return null | ||
| const isRemote = /^[a-z]+-remote:\/\//.test(e) | ||
| const isWorkspace = e.endsWith('.code-workspace') | ||
| const localPath = isRemote ? '' : uriToPath(e) | ||
| return { | ||
| name: path.basename(decodeURIComponent(e).replace(/^file:\/\//, '').replace(/^[a-z]+-remote:\/\//, '')) || '未命名', | ||
| path: localPath, | ||
| uri: e, | ||
| type: isRemote ? 'remote' : isWorkspace ? 'workspace' : 'folder', | ||
| label: '' | ||
| } | ||
| } | ||
| const uri = e.folderUri || e.fileUri || e.workspace?.configPath || '' | ||
| if (!uri) return null | ||
| const decoded = decodeURIComponent(uri) | ||
| const name = path.basename( | ||
| decoded.replace(/^file:\/\//, '').replace(/^[a-z]+-remote:\/\//, '') | ||
| ) | ||
| const isRemote = /^[a-z]+-remote:\/\//.test(uri) | ||
| const isWorkspace = uri.endsWith('.code-workspace') | ||
| const isFile = !!e.fileUri && !e.folderUri | ||
| const localPath = isRemote ? '' : uriToPath(uri) | ||
| return { | ||
| name: e.label || name || '未命名', | ||
| path: localPath, | ||
| uri, | ||
| type: isRemote ? 'remote' : isWorkspace ? 'workspace' : isFile ? 'file' : 'folder', | ||
| label: e.label || '' | ||
| } | ||
| }) | ||
| .filter(Boolean) | ||
| debugLog(`parseEntries 输出: ${result.length} 条`) | ||
| return result | ||
| } |
There was a problem hiding this comment.
健壮性:防御性处理 decodeURIComponent 异常
如果最近项目数据库中的 URI 包含不规范的百分比编码(例如单个 % 后面没有跟随合法的十六进制数,或包含非 UTF-8 字符),decodeURIComponent 会抛出 URIError 异常,导致整个项目列表加载失败。
改进建议:
封装一个 safeDecodeURIComponent 函数,使用 try...catch 包裹 decodeURIComponent,在解析失败时安全地返回原始字符串,确保插件的鲁棒性。
function safeDecodeURIComponent(str) {
try {
return decodeURIComponent(str)
} catch {
return str
}
}
function parseEntries(entries) {
debugLog(`parseEntries 输入: ${entries.length} 条`)
const result = entries
.map(e => {
if (e == null) return null
if (typeof e === 'string') {
if (!e) return null
const isRemote = /^[a-z]+-remote:\/\//.test(e)
const isWorkspace = e.endsWith('.code-workspace')
const localPath = isRemote ? '' : uriToPath(e)
return {
name: path.basename(safeDecodeURIComponent(e).replace(/^file:\/\//, '').replace(/^[a-z]+-remote:\/\//, '')) || '未命名',
path: localPath,
uri: e,
type: isRemote ? 'remote' : isWorkspace ? 'workspace' : 'folder',
label: ''
}
}
const uri = e.folderUri || e.fileUri || e.workspace?.configPath || ''
if (!uri) return null
const decoded = safeDecodeURIComponent(uri)
const name = path.basename(
decoded.replace(/^file:\/\//, '').replace(/^[a-z]+-remote:\/\//, '')
)
const isRemote = /^[a-z]+-remote:\/\//.test(uri)
const isWorkspace = uri.endsWith('.code-workspace')
const isFile = !!e.fileUri && !e.folderUri
const localPath = isRemote ? '' : uriToPath(uri)
return {
name: e.label || name || '未命名',
path: localPath,
uri,
type: isRemote ? 'remote' : isWorkspace ? 'workspace' : isFile ? 'file' : 'folder',
label: e.label || ''
}
})
.filter(Boolean)
debugLog(`parseEntries 输出: ${result.length} 条`)
return result
}| function uriToPath(uri) { | ||
| try { | ||
| const url = new URL(uri) | ||
| return decodeURIComponent(url.pathname) | ||
| } catch { | ||
| return uri | ||
| } | ||
| } |
There was a problem hiding this comment.
正确性:使用标准的 fileURLToPath 转换本地路径
手动使用 new URL(uri).pathname 并在 Windows 上进行 decodeURIComponent 可能会导致路径格式不正确(例如 Windows 盘符前会多出一个斜杠 /C:/...)。
改进建议:
使用 Node.js 内置的 node:url 模块中的 fileURLToPath 方法。它是官方推荐的、跨平台最鲁棒的将 file:// 协议 URI 转换为本地绝对路径的方法。
| function uriToPath(uri) { | |
| try { | |
| const url = new URL(uri) | |
| return decodeURIComponent(url.pathname) | |
| } catch { | |
| return uri | |
| } | |
| } | |
| function uriToPath(uri) { | |
| try { | |
| if (uri.startsWith('file://')) { | |
| return require('node:url').fileURLToPath(uri) | |
| } | |
| const url = new URL(uri) | |
| return decodeURIComponent(url.pathname) | |
| } catch { | |
| return uri | |
| } | |
| } |
| const doRegister = (list: IDEItem[]) => { | ||
| list.forEach(ide => { | ||
| if (!ide.code) return | ||
| try { window.ztools.setFeature({ code: ide.code, explain: `打开 ${ide.name || ide.code} 最近项目`, cmds: [ide.code], icon: 'logo.png' }) } | ||
| catch {} | ||
| }) | ||
| } | ||
|
|
||
| const save = () => { | ||
| if (!form.code.trim()) { alert('请输入别名'); return } | ||
| if (!form.command.trim()) { alert('请输入启动命令'); return } | ||
| if (!form.dbPath.trim()) { alert('请输入历史文件路径'); return } | ||
| const entry: IDEItem = { code: form.code.trim(), name: form.name.trim() || form.code.trim(), command: form.command.trim(), dbPath: form.dbPath.trim(), shell: form.shell.trim() } | ||
| let list | ||
| if (editingIdx >= 0) { | ||
| list = ides.map((item, i) => i === editingIdx ? entry : item) | ||
| } else { | ||
| list = [...ides, entry] | ||
| } | ||
| setIDEs(list) | ||
| saveIDEs(list) | ||
| doRegister(list) | ||
| setForm(emptyForm()) | ||
| setEditingIdx(-1) | ||
| setShowForm(false) | ||
| } | ||
|
|
||
| const del = (idx: number) => { | ||
| const removed = ides[idx] | ||
| const list = ides.filter((_, i) => i !== idx) | ||
| setIDEs(list) | ||
| saveIDEs(list) | ||
| try { if (removed?.code) window.ztools.removeFeature(removed.code) } catch {} | ||
| doRegister(list) | ||
| } |
There was a problem hiding this comment.
可维护性:移除冗余的快捷指令注册逻辑
在 store.ts 中,saveIDEs 已经调用了 window.services?.registerFeatures(),该方法会自动根据最新的 IDE 列表进行快捷指令的动态注册和旧指令的清理。
因此,在 Settings/index.tsx 中手动定义 doRegister、在 save 后调用 doRegister,以及在 del 中手动调用 removeFeature 和 doRegister 都是完全冗余的,且容易因重复调用 API 导致潜在的控制台报错。
改进建议:
删除 doRegister 函数,并简化 save 和 del 方法,完全信赖 saveIDEs 内部的自动注册与清理机制。
const save = () => {
if (!form.code.trim()) { alert('请输入别名'); return }
if (!form.command.trim()) { alert('请输入启动命令'); return }
if (!form.dbPath.trim()) { alert('请输入历史文件路径'); return }
const entry: IDEItem = { code: form.code.trim(), name: form.name.trim() || form.code.trim(), command: form.command.trim(), dbPath: form.dbPath.trim(), shell: form.shell.trim() }
let list
if (editingIdx >= 0) {
list = ides.map((item, i) => i === editingIdx ? entry : item)
} else {
list = [...ides, entry]
}
setIDEs(list)
saveIDEs(list)
setForm(emptyForm())
setEditingIdx(-1)
setShowForm(false)
}
const del = (idx: number) => {
const list = ides.filter((_, i) => i !== idx)
setIDEs(list)
saveIDEs(list)
}
插件信息
本次变更
截图 / 演示
自检清单
plugins/ide-open/目录此 PR 由 ztools-plugin-cli 自动管理:每次
ztools publish在分支上追加一个 commit,PR 链接保持不变。