fix(compat): Windows 兼容性修复 + 跨平台兼容分析
终端在 Windows 上起不来 (打包规则漏解包)
- 根因: build.asarUnpack 写的是 "**/node_modules/@lydell/node-pty-*/*", 只匹配一层,
于是 prebuilds/<plat>/ 下的文件没被解包, 留在 app.asar 里
- Windows 上这些必须是磁盘上的真实文件: conpty.dll (OS loader 加载)、
OpenConsole.exe (CreateProcess)、以及 node-pty 用 worker_threads.Worker 起的
conoutSocketWorker.js —— node-pty 本身就写了
__dirname.replace('node_modules.asar', 'node_modules.asar.unpacked') 来适配
- Linux 上看不出来: 只需要一个 pty.node, Electron 的 asar 层能 dlopen 归档里的 .node
- 修复: 规则递归化为 "**/node_modules/@lydell/node-pty-*/**/*"
- 验证: 重新打包后 pty.node 已落到 app.asar.unpacked, 安装版真实 spawn 成功
Windows 保留设备名 / 尾部点空格导致落盘写错
- 新增 src/main/safe-name.ts 的 safeFileName()
- Win32 把 CON/PRN/AUX/NUL/COM1-9/LPT1-9 (带扩展名也算) 当设备, open() 打开的不是文件;
还会悄悄吃掉文件名结尾的点和空格, 导致 existsSync 判断和实际落盘对不上
- 两处落盘都接上: file-server.ts 的 targetPath()、drive.ts 的 downloadDriveFile()
(后者名字来自对端共享目录, 属不可信输入)
- 策略是"最小必要": 非法字符全平台都清, 保留名和尾点空格只在 win32 上改写,
避免动到 Linux 上合法的文件名
其他
- 终端 cwd: Windows 优先用 USERPROFILE (装了 Git Bash 时 HOME 可能是 POSIX 路径, Win32 下不存在)
- USB 面板空列表提示按平台给, 不再一律说"Linux 用户需要在 dialout 组"
分析文档与自测
- 新增 docs/windows-compat.md: 各原生模块的 N-API 情况、已有 win32 分支的位置、
已知限制 (ConPTY 需 Win10 1809+、防火墙首次弹窗、USB 需 WinUSB 驱动等)、
以及一份 Windows 真机验证清单
- 自测新增 S1-S8 组: 保留名改写、尾部点空格、非法字符、跨平台不过度清洗、
超长截断、默认 shell/cwd、asarUnpack 规则回归护栏、上传落盘 + 路径穿越
- 全部 38 条用例通过
This commit is contained in:
+124
-1
@@ -11,7 +11,7 @@ import * as fs from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { connect } from 'node:net'
|
||||
import { createServer, Server } from 'node:http'
|
||||
import { createServer, Server, request as httpRequest } from 'node:http'
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
import { ChatServer } from '../../src/main/chat-server'
|
||||
@@ -19,6 +19,8 @@ import { ChatClient } from '../../src/main/chat-client'
|
||||
import { FileServer } from '../../src/main/file-server'
|
||||
import { listDrive, downloadDriveFile, collectDriveFiles } from '../../src/main/drive'
|
||||
import { computeWsState } from '../../src/main/ws-state'
|
||||
import { safeFileName } from '../../src/main/safe-name'
|
||||
import { defaultCwd, defaultShell } from '../../src/main/remote/shell'
|
||||
import { TerminalManager } from '../../src/main/remote/terminal'
|
||||
import { ForwardManager } from '../../src/main/remote/forward'
|
||||
import { UsbBridgeManager, toUsbControlSetup } from '../../src/main/remote/usb'
|
||||
@@ -645,6 +647,127 @@ async function main() {
|
||||
})
|
||||
}
|
||||
|
||||
// ============ 跨平台兼容 ============
|
||||
if (want('win') || want('compat') || want('platform')) {
|
||||
console.log('\n\x1b[1m[跨平台兼容]\x1b[0m')
|
||||
|
||||
await runCase('S1 Windows 保留设备名被改写 (CON/NUL/COM1 带扩展名也算)', async () => {
|
||||
for (const n of ['CON', 'con.txt', 'NUL', 'AUX', 'COM1', 'LPT1', 'clock$.log']) {
|
||||
const out = safeFileName(n, { platform: 'win32' })
|
||||
assert(!/^(CON|NUL|AUX|COM1|LPT1|CLOCK\$)/i.test(out), `"${n}" 没被处理: ${out}`)
|
||||
assert(out.startsWith('_'), `"${n}" 应该加前缀: ${out}`)
|
||||
}
|
||||
})
|
||||
|
||||
await runCase('S2 Windows 尾部点/空格被去掉 (Win32 会悄悄吞掉它们)', async () => {
|
||||
assert(safeFileName('report.', { platform: 'win32' }) === 'report', '尾部点没去掉')
|
||||
assert(safeFileName('report ', { platform: 'win32' }) === 'report', '尾部空格没去掉')
|
||||
assert(safeFileName('a b ', { platform: 'win32' }) === 'a b', '不该动中间的空格')
|
||||
})
|
||||
|
||||
await runCase('S3 非法字符全平台都清 (含路径分隔符)', async () => {
|
||||
for (const plat of ['win32', 'linux', 'darwin'] as NodeJS.Platform[]) {
|
||||
const out = safeFileName('a/b\\c:d*e?f"g<h>i|j', { platform: plat })
|
||||
assert(!/[/\\:*?"<>|]/.test(out), `${plat} 上还有非法字符: ${out}`)
|
||||
}
|
||||
})
|
||||
|
||||
await runCase('S4 Linux/macOS 不乱改合法文件名 (CON 在 Linux 上是合法文件)', async () => {
|
||||
assert(safeFileName('CON', { platform: 'linux' }) === 'CON', 'Linux 上不该改 CON')
|
||||
assert(safeFileName('a:b', { platform: 'darwin' }) === 'a_b', '冒号还是该清')
|
||||
})
|
||||
|
||||
await runCase('S5 超长文件名被截断且不超上限', async () => {
|
||||
const out = safeFileName('x'.repeat(500) + '.txt', { platform: 'linux' })
|
||||
assert(out.length <= 200, `没截断: ${out.length}`)
|
||||
assert(out.endsWith('.txt'), '扩展名丢了')
|
||||
})
|
||||
|
||||
await runCase('S6 默认 shell / cwd 可用', async () => {
|
||||
const sh = defaultShell()
|
||||
assert(sh && sh.file, '没选到 shell')
|
||||
const cwd = defaultCwd()
|
||||
assert(cwd && existsSync(cwd), `cwd 不存在: ${cwd}`)
|
||||
})
|
||||
|
||||
await runCase('S7 打包配置: node-pty 平台包必须递归解包 (否则 Windows 终端起不来)', async () => {
|
||||
// 打包后 __dirname 在 scripts/selftest/.out, 向上找到带 build.asarUnpack 的 package.json
|
||||
let dir = __dirname
|
||||
let pkg: any = null
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const f = join(dir, 'package.json')
|
||||
if (existsSync(f)) {
|
||||
try {
|
||||
const j = JSON.parse(readFileSync(f, 'utf8'))
|
||||
if (j?.build?.asarUnpack) { pkg = j; break }
|
||||
} catch {}
|
||||
}
|
||||
dir = dirname(dir)
|
||||
}
|
||||
assert(pkg, '没找到带 build.asarUnpack 的 package.json')
|
||||
const pats: string[] = pkg.build.asarUnpack || []
|
||||
const ptyPat = pats.find(p => p.includes('@lydell/node-pty-*'))
|
||||
assert(ptyPat, `asarUnpack 里没有 node-pty 平台包的规则: ${pats.join(' | ')}`)
|
||||
assert(
|
||||
ptyPat.endsWith('/**/*'),
|
||||
`node-pty 平台包规则必须递归 (/**/*), 否则 prebuilds/<plat>/ 里的 .node/.dll/.exe 会留在 asar 里: ${ptyPat}`,
|
||||
)
|
||||
})
|
||||
await runCase('S8 上传落盘: 文件名按本机平台清洗, 且不逃出接收目录', async () => {
|
||||
const root = join(tmpdir(), `lnm-selftest-up-${Date.now()}`)
|
||||
mkdirSync(root, { recursive: true })
|
||||
const fsrv = new FileServer(0, root, {
|
||||
getDriveRoot: () => '', getDriveToken: () => '', isDriveEnabled: () => false,
|
||||
})
|
||||
const port = await fsrv.start()
|
||||
const upload = async (name: string, body: string) => {
|
||||
const buf = Buffer.from(body)
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
const req = httpRequest({
|
||||
host: '127.0.0.1', port, path: '/upload', method: 'POST',
|
||||
headers: {
|
||||
'x-file-id': `selftest-up-${Math.random().toString(36).slice(2)}`,
|
||||
'x-file-name': encodeURIComponent(name),
|
||||
'x-file-size': String(buf.length),
|
||||
'x-file-mime': 'text/plain',
|
||||
'Content-Length': String(buf.length),
|
||||
},
|
||||
}, r => {
|
||||
let b = ''
|
||||
r.on('data', d => (b += d))
|
||||
r.on('end', () => { try { resolve(JSON.parse(b)) } catch (e) { reject(e) } })
|
||||
})
|
||||
req.on('error', reject)
|
||||
req.end(buf)
|
||||
})
|
||||
}
|
||||
try {
|
||||
// 1) Windows 保留设备名: 在 Linux 上是合法文件名, 不该被改;
|
||||
// 在 Windows 上必须被改写 (S1 已覆盖 win32 分支)。这里校验"落盘名 == 清洗结果"。
|
||||
const r1 = await upload('CON.txt', 'UPLOAD_BODY')
|
||||
assert(r1?.ok, `上传失败: ${JSON.stringify(r1)}`)
|
||||
const saved1: string = r1.savedPath
|
||||
assert(existsSync(saved1), `文件没落盘: ${saved1}`)
|
||||
assert(readFileSync(saved1, 'utf8') === 'UPLOAD_BODY', '内容不对')
|
||||
const expect1 = safeFileName('CON.txt', { platform: process.platform })
|
||||
assert((saved1.split(/[\\/]/).pop() || '') === expect1,
|
||||
`落盘名与清洗结果不一致: ${saved1} vs ${expect1}`)
|
||||
|
||||
// 2) 路径穿越: 文件名里带 ../ 必须被清洗掉, 不能写到接收目录外面
|
||||
const r2 = await upload('../../evil.txt', 'EVIL')
|
||||
assert(r2?.ok, `上传失败: ${JSON.stringify(r2)}`)
|
||||
const saved2: string = r2.savedPath
|
||||
assert(existsSync(saved2), `文件没落盘: ${saved2}`)
|
||||
const rel = saved2.slice(root.length).replace(/^[\\/]+/, '')
|
||||
assert(!rel.split(/[\\/]/).includes('..'), `路径穿越没挡住: ${saved2}`)
|
||||
assert(!saved2.includes(`${root}..`), `路径穿越没挡住: ${saved2}`)
|
||||
} finally {
|
||||
await fsrv.stop()
|
||||
try { rmSync(root, { recursive: true, force: true }) } catch {}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ============ 审计 ============
|
||||
if (want('audit')) {
|
||||
console.log('\n\x1b[1m[审计]\x1b[0m')
|
||||
|
||||
Reference in New Issue
Block a user