// LocalNetMsg 远程功能自测 (terminal / forward / usb) // 在一个进程里跑两个虚拟节点 (A = 控制端, B = 被控端), 走真实的 // ChatServer + ChatClient + 三个 remote manager, 接线方式与 src/main/ipc.ts 完全一致. // // 用法: // npm run selftest # 全部 // npm run selftest -- terminal # 只跑某些用例 import { spawn, ChildProcess } from 'node:child_process' import { existsSync, mkdirSync, rmSync, openSync, closeSync, readSync, writeSync, writeFileSync, readFileSync } from 'node:fs' 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, request as httpRequest } from 'node:http' import { createHash } from 'node:crypto' import { ChatServer } from '../../src/main/chat-server' 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' import { updateSettings } from '../../src/main/settings' import { listAudit } from '../../src/main/db' import type { DeviceInfo, WsFrame, TerminalFrame, ForwardFrame, UsbFrame } from '../../src/main/protocol' // ============ 小型测试框架 ============ interface CaseResult { name: string; ok: boolean; detail: string; ms: number } const results: CaseResult[] = [] let current = '' function assert(cond: any, msg: string) { if (!cond) throw new Error(msg) } function sleep(ms: number) { return new Promise(r => setTimeout(r, ms)) } async function runCase(name: string, fn: () => Promise) { current = name const t0 = Date.now() try { await fn() results.push({ name, ok: true, detail: '', ms: Date.now() - t0 }) console.log(` \x1b[32mPASS\x1b[0m ${name} (${Date.now() - t0}ms)`) } catch (e: any) { const detail = String(e?.message || e) results.push({ name, ok: false, detail, ms: Date.now() - t0 }) console.log(` \x1b[31mFAIL\x1b[0m ${name} (${Date.now() - t0}ms): ${detail}`) } } // ============ 虚拟节点 ============ interface UiEvent { kind: string; sessionId?: string; peerId?: string; data?: string; text?: string; reason?: string } class Node { info: DeviceInfo server: ChatServer client: ChatClient fileServer: FileServer terminal: TerminalManager forward: ForwardManager usb: UsbBridgeManager events: UiEvent[] = [] driveDir = '' driveToken = '' constructor(id: string, name: string) { this.info = { deviceId: id, name, hostname: name, platform: 'linux', version: 1, appVersion: '0.1.0', address: '127.0.0.1', chatPort: 0, filePort: 0, ts: Date.now(), } this.server = new ChatServer(0) this.client = new ChatClient(this.info) this.fileServer = new FileServer(0, join(tmpdir(), `lnm-selftest-dl-${id.slice(-4)}`), { getDriveRoot: () => this.driveDir, getDriveToken: () => this.driveToken, isDriveEnabled: () => true, }) this.terminal = new TerminalManager(this.client, id) this.forward = new ForwardManager(this.client) this.usb = new UsbBridgeManager(this.client) } async start() { const port = await this.server.start() this.info.chatPort = port const filePort = await this.fileServer.start() this.info.filePort = filePort // 网盘目录: 放几个测试文件 this.driveDir = join(tmpdir(), `lnm-selftest-drive-${this.info.deviceId.slice(-4)}`) rmSync(this.driveDir, { recursive: true, force: true }) mkdirSync(join(this.driveDir, 'sub', 'deep'), { recursive: true }) writeFileSync(join(this.driveDir, 'hello.txt'), 'HELLO-FROM-DRIVE') writeFileSync(join(this.driveDir, 'sub', 'nested.txt'), 'NESTED-FROM-DRIVE') writeFileSync(join(this.driveDir, 'sub', 'deep', 'deeper.txt'), 'DEEPER-FROM-DRIVE') this.driveToken = `tok-${this.info.deviceId.slice(-4)}` this.info.driveToken = this.driveToken // ---- 与 src/main/ipc.ts 完全一致的帧路由 ---- const TRACE = !!process.env.LNM_TRACE const route = (peer: DeviceInfo, frame: WsFrame) => { if (TRACE) { const p: any = (frame as any).payload console.log(`[trace ${this.info.name}] <- ${frame.type}${p ? `/${p.type}` : ''} ${p?.dir || ''} ${p?.sessionId?.slice(0, 8) || ''} ${p?.data ? `(${Buffer.from(p.data, 'base64').length}B)` : ''}`) } if (frame.type === 'terminal') { this.terminal.handleIncoming(peer, frame.payload as TerminalFrame) .catch((e) => console.warn(`[${this.info.name}] terminal handle error:`, e?.message)) } else if (frame.type === 'forward') { this.forward.handleIncoming(peer, frame.payload as ForwardFrame) } else if (frame.type === 'usb') { this.usb.handleIncoming(peer, frame.payload as UsbFrame) } } this.server.on('frame', route) this.client.on('message', route) // ---- 渲染层能看到的事件 (对应 ipc.ts 里 broadcastToRenderer) ---- this.terminal.on('output', (sessionId, bytes, peerId) => this.events.push({ kind: 'terminal:output', sessionId, peerId, data: bytes.toString('base64') })) this.terminal.on('sessionOpened', (sessionId, peerId) => this.events.push({ kind: 'terminal:opened', sessionId, peerId })) this.terminal.on('sessionClosed', (sessionId, peerId, reason) => this.events.push({ kind: 'terminal:closed', sessionId, peerId, reason })) this.terminal.on('approvalRequested', (req) => this.events.push({ kind: 'approval', peerId: req.peerId, text: req.detail })) this.forward.on('sessionOpened', (i: any) => this.events.push({ kind: 'forward:opened', sessionId: i.sessionId, peerId: i.peerId })) this.forward.on('sessionClosed', (i: any) => this.events.push({ kind: 'forward:closed', sessionId: i.sessionId, peerId: i.peerId, reason: i.reason })) this.usb.on('output', (sessionId, bytes, peerId) => this.events.push({ kind: 'usb:output', sessionId, peerId, data: bytes.toString('base64') })) this.usb.on('sessionOpened', (i: any) => this.events.push({ kind: 'usb:opened', sessionId: i.sessionId, peerId: i.peerId })) this.usb.on('sessionClosed', (i: any) => this.events.push({ kind: 'usb:closed', sessionId: i.sessionId, peerId: i.peerId, reason: i.reason })) this.usb.on('sessionError', (i: any) => this.events.push({ kind: 'usb:error', sessionId: i.sessionId, peerId: i.peerId, text: i.error })) this.client.on('error', (_peer, err) => console.warn(`[${this.info.name}] ws error: ${err.message}`)) // 追踪本端发出的帧 if (TRACE) { const origSend = this.client.send.bind(this.client) ;(this.client as any).send = (frame: WsFrame, deviceId?: string) => { const p: any = (frame as any).payload console.log(`[trace ${this.info.name}] -> ${frame.type}${p ? `/${p.type}` : ''} ${p?.dir || ''} ${p?.sessionId?.slice(0, 8) || ''} ${p?.data ? `(${Buffer.from(p.data, 'base64').length}B)` : ''}`) return origSend(frame, deviceId) } } } connectTo(other: Node) { this.client.connectTo({ ...other.info }) } // 该节点收到的某个 kind 事件 (最近 n 秒内) hasEvent(kind: string, pred?: (e: UiEvent) => boolean) { return this.events.some(e => e.kind === kind && (!pred || pred(e))) } findEvents(kind: string) { return this.events.filter(e => e.kind === kind) } clearEvents() { this.events = [] } async stop() { try { await this.server.stop() } catch {} try { this.client.stop() } catch {} try { this.terminal.stop() } catch {} try { this.forward.stop() } catch {} try { this.usb.stop() } catch {} } } function decode(b64: string) { return Buffer.from(b64, 'base64').toString('utf8') } // ============ 小工具 ============ function startHttpServer(port: number, body: string): Promise { return new Promise((resolve, reject) => { const s = createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }) res.end(body) }) s.on('error', reject) s.listen(port, '127.0.0.1', () => resolve(s)) }) } // 发起 HTTP 请求, 收到完整响应后**保持连接** (隧道是单连接设计, 连接断了整个转发就结束) function httpGet(port: number, path = '/', timeoutMs = 5000): Promise<{ body: string; sock: Socket }> { return new Promise((resolve, reject) => { const req = connect({ host: '127.0.0.1', port }, () => { req.write(`GET ${path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: keep-alive\r\n\r\n`) }) let buf = '' let settled = false const finish = () => { if (settled) return settled = true clearTimeout(timer) resolve({ body: buf, sock: req }) } // 目标服务是 keep-alive, 连接不会自己关 —— 按 Content-Length 判完整, 别等 'end' const tryComplete = () => { const idx = buf.indexOf('\r\n\r\n') if (idx < 0) return const m = /content-length:\s*(\d+)/i.exec(buf.slice(0, idx)) const need = m ? Number(m[1]) : 0 if (buf.length >= idx + 4 + need) finish() } const timer = setTimeout(() => { if (!settled) { settled = true; reject(new Error(`http 超时 (收到 ${buf.length}B): ${buf.slice(0, 120)}`)) } }, timeoutMs) req.on('data', (d) => { buf += d.toString('utf8'); tryComplete() }) req.on('end', () => finish()) req.on('error', (e) => { if (!settled) { settled = true; clearTimeout(timer); reject(e) } }) }) } function waitFor(pred: () => boolean, timeoutMs = 5000, stepMs = 50): Promise { return new Promise((resolve, reject) => { const t0 = Date.now() const tick = () => { if (pred()) return resolve() if (Date.now() - t0 > timeoutMs) return reject(new Error('等待超时')) setTimeout(tick, stepMs) } tick() }) } // 造一个 socat 串口对: 一头是 PTY (给被控端当真实串口), 另一头我们直接读写 async function startSocatSerial(linkDir: string): Promise<{ proc: ChildProcess; userPath: string; internalPath: string }> { const userPath = join(linkDir, 'user') const internalPath = join(linkDir, 'internal') const proc = spawn('socat', [ '-d', '-d', `pty,raw,echo=0,link=${userPath}`, `pty,raw,echo=0,link=${internalPath}`, ], { stdio: ['ignore', 'pipe', 'pipe'] }) let err = '' proc.stderr?.on('data', (b) => { err += b.toString() }) const t0 = Date.now() while (!(existsSync(userPath) && existsSync(internalPath))) { if (Date.now() - t0 > 5000) { proc.kill(); throw new Error('socat 起不来: ' + err) } await sleep(100) } return { proc, userPath, internalPath } } // 打开 PTY 的一端做非阻塞读写 function openPty(path: string) { const fd = openSync(path, fs.constants.O_RDWR | fs.constants.O_NONBLOCK) return { fd, write: (s: string | Buffer) => writeSync(fd, s), read: (): string => { const buf = Buffer.alloc(4096) try { const n = readSync(fd, buf, 0, buf.length, null) return n > 0 ? buf.slice(0, n).toString('utf8') : '' } catch (e: any) { if (e?.code === 'EAGAIN' || e?.code === 'EWOULDBLOCK') return '' throw e } }, close: () => { try { closeSync(fd) } catch {} }, } } // ============ 主流程 ============ async function main() { const filter = process.argv.slice(2).filter(a => !a.startsWith('-')) const want = (name: string) => !filter.length || filter.some(f => name.toLowerCase().includes(f.toLowerCase())) const step = (m: string) => console.log(`[step] ${m}`) step('建节点 A') const A = new Node('peer-aaaaaaaa-0000-4000-8000-00000000000a', '控制端A') step('建节点 B') const B = new Node('peer-bbbbbbbb-0000-4000-8000-00000000000b', '被控端B') step('启动 A') await A.start() step('启动 B') await B.start() step(`节点就绪 A(:${A.info.chatPort}) B(:${B.info.chatPort})`) // 双向连通 (模拟 discovery 找到对方后双方都连上) step('建立 WS 连接') A.connectTo(B) B.connectTo(A) await waitFor(() => A.client.isOpen(B.info.deviceId) && B.client.isOpen(A.info.deviceId), 8000) console.log(`\n两个节点已连通: A(:${A.info.chatPort}) <-> B(:${B.info.chatPort})\n`) // 授权: 双方互相放行, 避免弹窗 (等价于设置里勾了"允许") updateSettings({ remoteEnabled: true, remoteAllowPeers: { [A.info.deviceId]: { terminal: true, forward: true, usb: true }, [B.info.deviceId]: { terminal: true, forward: true, usb: true }, }, }) // ============ 终端 ============ console.log('\x1b[1m[终端]\x1b[0m') let termSessionId = '' const TERM_MARK = 'LNM_TERM_SELFTEST' if (want('terminal') || want('term')) { await runCase('T1 打开终端: 控制端收到 ack ok', async () => { const r = await A.terminal.openOnPeer(B.info.deviceId, { rows: 24, cols: 80 }) assert(r.ok, `open 被拒: ${r.reason}`) assert(!!r.sessionId, '没有 sessionId') termSessionId = r.sessionId! }) await runCase('T2 控制端能收到终端输出 (提示符/回显)', async () => { const got = await waitFor(() => A.hasEvent('terminal:output'), 6000).then(() => true).catch(() => false) assert(got, '控制端 6s 内没有收到任何 terminal:output 事件 —— 终端画面是空的') const text = A.findEvents('terminal:output').map(e => decode(e.data!)).join('') assert(text.length > 0, '收到的输出为空') }) await runCase('T3 输入命令, 输出能回显到控制端', async () => { A.clearEvents() const ok = A.terminal.sendInput(termSessionId, `echo ${TERM_MARK}\n`) assert(ok, 'sendInput 失败 (会话不存在?)') await waitFor(() => A.findEvents('terminal:output').map(e => decode(e.data!)).join('').includes(TERM_MARK), 8000) .catch(() => { throw new Error('8s 内没有看到命令回显 ' + TERM_MARK) }) }) await runCase('T4 窗口尺寸 resize 能传到被控端', async () => { A.terminal.resize(termSessionId, 40, 120) await sleep(300) const sess = B.terminal.listServerSessions().find(s => s.sessionId === termSessionId) assert(!!sess, '被控端没有这个 session') assert(sess!.rows === 40 && sess!.cols === 120, `被控端尺寸不对: ${sess!.rows}x${sess!.cols}`) }) await runCase('T5 关闭终端: 被控端 session 被回收', async () => { A.terminal.close(termSessionId, 'selftest') await waitFor(() => B.terminal.listServerSessions().every(s => s.sessionId !== termSessionId), 5000) .catch(() => { throw new Error('被控端 session 没有被回收') }) }) } // ============ 端口转发 ============ console.log('\n\x1b[1m[端口转发]\x1b[0m') if (want('forward') || want('for')) { const targetPort = 47881 // 被控端的目标服务 const listenPort = 47882 // 控制端的本地监听 const peerListenPort = 47883 // 被控端的监听 (self-in) const ctrlTargetPort = 47884 // 控制端的目标服务 (self-in) const peerTarget = await startHttpServer(targetPort, 'HELLO-FROM-PEER') const ctrlTarget = await startHttpServer(ctrlTargetPort, 'HELLO-FROM-CONTROL') const openSocks: Socket[] = [] try { await runCase('F1 self-out: 控制端 127.0.0.1:47882 拿到被控端服务内容', async () => { const r = await A.forward.openOnPeer(B.info.deviceId, { direction: 'self-out', listenPort, targetHost: '127.0.0.1', targetPort, ttlSec: 120, }) assert(r.ok, `open 被拒: ${r.reason}`) const { body, sock } = await httpGet(listenPort) openSocks.push(sock) assert(body.includes('HELLO-FROM-PEER'), `拿到的是: ${body.slice(0, 120)}`) }) await runCase('F2 self-in: 被控端 127.0.0.1:47883 拿到控制端服务内容', async () => { const r = await A.forward.openOnPeer(B.info.deviceId, { direction: 'self-in', listenPort: peerListenPort, targetHost: '127.0.0.1', targetPort: ctrlTargetPort, ttlSec: 120, }) assert(r.ok, `open 被拒: ${r.reason}`) const { body, sock } = await httpGet(peerListenPort) openSocks.push(sock) assert(body.includes('HELLO-FROM-CONTROL'), `拿到的是: ${body.slice(0, 120)}`) }) await runCase('F3 拒绝把本机自身端口 (47900) 作为转发目标', async () => { const r = await A.forward.openOnPeer(B.info.deviceId, { direction: 'self-out', listenPort: 47890, targetHost: '127.0.0.1', targetPort: 47900, ttlSec: 60, }) assert(!r.ok, '自身端口竟然被放行了') }) await runCase('F4 转发字节计数在两端都有记录', async () => { const cs = A.forward.listClientSessions() assert(cs.length > 0, '控制端没有转发 session') const s = cs[0] assert(s.bytesIn > 0 && s.bytesOut > 0, `字节计数不对: in=${s.bytesIn} out=${s.bytesOut}`) }) await runCase('F5 关掉"对方发起的"转发: 对端 session 和监听端口都要释放', async () => { // 由 B 发起: B bind 47885, A 作为 server 侧连 B 填的目标 const r = await B.forward.openOnPeer(A.info.deviceId, { direction: 'self-out', listenPort: 47885, targetHost: '127.0.0.1', targetPort: targetPort, ttlSec: 300, }) assert(r.ok, `B 发起转发失败: ${r.reason}`) const serverSess = A.forward.listServerSessions().find(s => s.sessionId === r.sessionId) assert(!!serverSess, 'A 侧没有对应的 server session (对方发起的)') assert(B.forward.listClientSessions().some(s => s.sessionId === r.sessionId), 'B 侧 client session 不存在') // A 在 UI 上点"关闭" (对方发起的) —— 走 closeSession, 要能关掉 server 侧 const closed = A.forward.closeSession(r.sessionId!, 'user') assert(closed, 'closeSession 没关掉任何东西') assert(A.forward.listServerSessions().every(s => s.sessionId !== r.sessionId), 'A 侧 server session 还在') // 关键: close 帧要发到对端, B 的 client session 及其监听端口要一起回收 await waitFor(() => B.forward.listClientSessions().every(s => s.sessionId !== r.sessionId), 5000) .catch(() => { throw new Error('对端的 client session 没被回收 (close 帧没发出去?)') }) let portStillOpen = true try { await new Promise((res, rej) => { const sock = connect({ host: '127.0.0.1', port: 47885 }, () => { sock.destroy(); res() }) sock.on('error', () => rej(new Error('refused'))) setTimeout(() => { sock.destroy(); rej(new Error('timeout')) }, 2000) }) } catch { portStillOpen = false } assert(!portStillOpen, '对端的监听端口 47885 还占着, 没释放') }) } finally { for (const s of openSocks) { try { s.destroy() } catch {} } peerTarget.close(); ctrlTarget.close() } } // ============ USB / 串口 ============ console.log('\n\x1b[1m[USB / 串口]\x1b[0m') if (want('usb')) { await runCase('U1 列对方设备: 请求-响应链路通', async () => { const r = await A.usb.listOnPeer(B.info.deviceId) assert(r.ok, `list 失败: ${r.reason}`) assert(Array.isArray(r.devices), 'devices 不是数组') }) await runCase('U2 USB 模式设备枚举 (libusb) 能列出真机设备', async () => { const devs = await B.usb.listLocal() const usbDevs = devs.filter(d => d.kind === 'usb') console.log(` (枚举到 ${devs.length} 个设备, 其中 usb ${usbDevs.length} 个)`) assert(usbDevs.length > 0, '一个 libusb 设备都没枚举到 —— USB 模式不可用') }) await runCase('U2b 内核占用的设备: attach 失败原因能正确回传到控制端', async () => { const devs = await B.usb.listLocal() const usbDevs = devs.filter(d => d.kind === 'usb') assert(usbDevs.length > 0, '没有可用的 usb 设备') // 这台机器上的设备都被内核驱动占着 (键鼠/摄像头/蓝牙), 测试里不让它抢驱动 const pick = usbDevs.find(d => /hub/i.test(d.product || '')) || usbDevs[0] console.log(` (选用设备 ${pick.busId} ${pick.product || ''})`) const r = await A.usb.attach(B.info.deviceId, { busId: pick.busId, direction: 'self-out', config: { kind: 'usb', configurationValue: 1, interfaceNumber: 0, detachKernelDriver: false }, }) // 期望: 要么成功, 要么失败原因能传回来 (而不是超时/静默) if (!r.ok) { console.log(` (按预期被内核占用挡住: ${r.reason})`) assert(!!r.reason && r.reason.length > 0, '失败时没有给出原因') } }) await runCase('U2c 控制传输 setup 翻译正确, 真机 GET_DESCRIPTOR 能拿到数据', async () => { const usbMod: any = require('usb') const devs = await usbMod.webusb.loadDevices() assert(devs.length > 0, 'libusb 枚举不到设备') const dev = devs.find((d: any) => d.vendorId === 0x0c45 && d.productId === 0x6366) || devs[0] await dev.open() try { // 用应用里同一套翻译函数 (bmRequestType 数值 -> node-usb v3 字符串枚举) const setup = toUsbControlSetup({ requestType: 0x80, request: 0x06, value: 0x0100, index: 0x0000 }) const buf = await dev.nativeControlTransferIn(setup, 3000, 18) assert(!!buf && buf.length === 18, `描述符长度不对: ${buf?.length}`) const hexd = Buffer.from(buf).toString('hex') console.log(` (descriptor: ${hexd})`) assert(hexd.startsWith('1201'), '不是合法的设备描述符') } finally { await dev.close().catch(() => {}) } }) // 串口双向字节流: 用 socat 造一个 PTY 对冒充"真串口" const vcomDir = join(tmpdir(), `lnm-selftest-vcom-${Date.now()}`) mkdirSync(vcomDir, { recursive: true }) let socatProc: ChildProcess | null = null let devEnd: ReturnType | null = null try { const pair = await startSocatSerial(vcomDir) socatProc = pair.proc // 被控端持有 "user" 端 (当真实串口), 测试这边拿 "internal" 端当外部设备 B.usb.listLocal = async () => ([{ busId: `serial:${pair.userPath}`, vid: 0, pid: 0, deviceClass: 0x02, deviceSubclass: 0x02, product: 'selftest-pty', manufacturer: 'socat', kind: 'serial', serialPath: pair.userPath, baudRate: 115200, }] as any) await runCase('U3 attach 串口: 控制端能拿到 attached ok', async () => { const r = await A.usb.attach(B.info.deviceId, { busId: `serial:${pair.userPath}`, direction: 'self-out', config: { kind: 'serial', baudRate: 115200 }, }) assert(r.ok, `attach 被拒: ${r.reason}`) }) await runCase('U4 串口双向字节流: 控制端 -> 设备', async () => { const sess = A.usb.listClientSessions()[0] assert(!!sess, '控制端没有 usb session') devEnd = openPty(pair.internalPath) const ok = await A.usb.sendSerialBytes(sess.sessionId, Buffer.from('HELLO-FROM-CONTROL\n')) assert(ok, 'sendSerialBytes 失败') // 设备端应该收到 let seen = '' for (let i = 0; i < 50 && !seen.includes('HELLO-FROM-CONTROL'); i++) { await sleep(100) seen += devEnd.read() } assert(seen.includes('HELLO-FROM-CONTROL'), `设备端没收到: ${JSON.stringify(seen)}`) }) await runCase('U5 串口双向字节流: 设备 -> 控制端', async () => { assert(!!devEnd, '设备端没准备好') A.clearEvents() devEnd.write('HELLO-FROM-DEVICE\n') await waitFor(() => A.findEvents('usb:output').map(e => decode(e.data!)).join('').includes('HELLO-FROM-DEVICE'), 6000) .catch(() => { throw new Error('控制端没收到设备发来的字节') }) }) } finally { try { devEnd?.close() } catch {} try { socatProc?.kill() } catch {} try { rmSync(vcomDir, { recursive: true, force: true }) } catch {} } } // ============ 网盘 ============ console.log('\n\x1b[1m[网盘]\x1b[0m') if (want('drive') || want('网盘')) { await runCase('D1 列对方网盘根目录', async () => { const r = await listDrive(B.info, '') assert(r.ok, `列目录失败: ${r.reason}`) const names = (r.entries || []).map(e => e.name) assert(names.includes('hello.txt'), `没看到 hello.txt, 实际: ${names.join(',')}`) assert(names.includes('sub'), `没看到子目录 sub, 实际: ${names.join(',')}`) const f = (r.entries || []).find(e => e.name === 'hello.txt')! assert(f.size === 'HELLO-FROM-DRIVE'.length, `大小不对: ${f.size}`) assert(!f.isDir, '文件被标成目录了') }) await runCase('D2 进子目录再列一次', async () => { const r = await listDrive(B.info, 'sub') assert(r.ok, `列子目录失败: ${r.reason}`) const names = (r.entries || []).map(e => e.name) assert(names.includes('nested.txt'), `实际: ${names.join(',')}`) }) await runCase('D3 下载文件: 内容一致', async () => { const dest = join(tmpdir(), `lnm-selftest-dl-${Date.now()}`) mkdirSync(dest, { recursive: true }) const r = await downloadDriveFile({ peer: B.info, relPath: 'hello.txt', destDir: dest }) assert(r.ok, `下载失败: ${r.reason}`) const content = readFileSync(r.path!, 'utf8') assert(content === 'HELLO-FROM-DRIVE', `内容不对: ${content}`) assert(r.size === 'HELLO-FROM-DRIVE'.length, `大小不对: ${r.size}`) }) await runCase('D4 下载子目录里的文件', async () => { const dest = join(tmpdir(), `lnm-selftest-dl2-${Date.now()}`) mkdirSync(dest, { recursive: true }) const r = await downloadDriveFile({ peer: B.info, relPath: 'sub/nested.txt', destDir: dest }) assert(r.ok, `下载失败: ${r.reason}`) assert(readFileSync(r.path!, 'utf8') === 'NESTED-FROM-DRIVE', '内容不对') }) await runCase('D5 目录穿越被挡住 (.. 逃不出网盘根)', async () => { const r = await listDrive(B.info, '../../../../etc') assert(!r.ok, '竟然列出了网盘之外的目录!') const r2 = await listDrive(B.info, '..') assert(!r2.ok, '竟然能列出上级目录!') }) await runCase('D6 token 错误 -> 拒绝访问', async () => { const bad = { ...B.info, driveToken: 'wrong-token' } const r = await listDrive(bad, '') assert(!r.ok, '错误 token 竟然能访问!') assert(/令牌/.test(r.reason || ''), `错误信息不对: ${r.reason}`) }) await runCase('D7 对方没开网盘 (无 token) -> 明确提示不支持', async () => { const r = await listDrive({ ...B.info, driveToken: undefined }, '') assert(!r.ok, '无 token 竟然能访问!') assert(/不支持/.test(r.reason || ''), `错误信息不对: ${r.reason}`) }) await runCase('D8 递归收集目录下的全部文件', async () => { const r = await collectDriveFiles(B.info, 'sub') assert(r.ok, `收集失败: ${r.reason}`) const names = r.files.map(f => f.relPath).sort() assert(names.includes('sub/nested.txt'), `缺 nested.txt: ${names.join(',')}`) assert(names.includes('sub/deep/deeper.txt'), `缺深层文件: ${names.join(',')}`) }) await runCase('D9 整目录下载: 保持层级', async () => { const dest = join(tmpdir(), `lnm-selftest-dl3-${Date.now()}`) mkdirSync(dest, { recursive: true }) const collected = await collectDriveFiles(B.info, 'sub') assert(collected.ok, '收集失败') for (const f of collected.files) { const relInside = f.relPath.slice('sub'.length).replace(/^[/\\]+/, '') const dirInside = relInside ? dirname(relInside) : '' const destSubPath = dirInside && dirInside !== '.' ? join('sub', dirInside) : 'sub' const r = await downloadDriveFile({ peer: B.info, relPath: f.relPath, destDir: dest, destSubPath }) assert(r.ok, `下载 ${f.relPath} 失败: ${r.reason}`) } assert(existsSync(join(dest, 'sub', 'nested.txt')), '层级不对: 少了 sub/nested.txt') assert(existsSync(join(dest, 'sub', 'deep', 'deeper.txt')), '层级不对: 少了 sub/deep/deeper.txt') assert(readFileSync(join(dest, 'sub', 'deep', 'deeper.txt'), 'utf8') === 'DEEPER-FROM-DRIVE', '内容不对') }) } // ============ 消息通道状态 ============ // 回归: 启动时主进程先连上对端, 渲染层后订阅 -> open 事件被丢掉, UI 永久显示"等待重连" // 所以状态必须是"拉取式"的: 任何时刻去查都能查到当前真实状态 if (want('ws') || want('state')) { console.log('\n\x1b[1m[通道状态]\x1b[0m') await runCase('W1 通道状态可随时拉取 (连接早已建好, 现在才查也查得到)', async () => { assert(A.client.isOpen(B.info.deviceId), '前提不成立: A->B 的 outgoing 没开') // 模拟"渲染层晚订阅": 此刻才去查状态 const st = computeWsState([B.info.deviceId], A.client.getStatus(), A.server) assert(st[B.info.deviceId] === true, `状态不对: ${JSON.stringify(st)}`) }) await runCase('W2 只有 inbound 通 (对方连我, 我没主动连) 也算通道可用', async () => { const C = new Node('peer-cccccccc-0000-4000-8000-00000000000c', '只入站C') await C.start() try { B.connectTo(C) // 只有 B -> C 这一条腿 await waitFor(() => C.server.isConnected(B.info.deviceId), 8000) assert(!C.client.isOpen(B.info.deviceId), '前提不成立: C 不该有 outgoing 连接') const st = computeWsState([B.info.deviceId], C.client.getStatus(), C.server) assert(st[B.info.deviceId] === true, `只有 inbound 通时不该判死: ${JSON.stringify(st)}`) } finally { await C.stop() } }) await runCase('W3 两条腿都断了 -> 状态回落 false', async () => { A.client.remove(B.info.deviceId) B.client.remove(A.info.deviceId) await waitFor(() => !A.server.isConnected(B.info.deviceId), 5000) await waitFor(() => !B.server.isConnected(A.info.deviceId), 5000) const st = computeWsState([B.info.deviceId], A.client.getStatus(), A.server) assert(st[B.info.deviceId] === false, `断了还报可用: ${JSON.stringify(st)}`) }) } // ============ 跨平台兼容 ============ 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"gi|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// 里的 .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((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') await runCase('A1 关键操作都写进了 audit_log', async () => { const rows = listAudit({ limit: 200 }) const actions = new Set(rows.map(r => r.action)) const need = ['terminal.open', 'forward.open'] for (const n of need) assert(actions.has(n), `audit 里没有 ${n} (现有: ${[...actions].join(', ')})`) }) } // ============ 汇总 ============ await A.stop() await B.stop() const pass = results.filter(r => r.ok).length const fail = results.length - pass console.log(`\n\x1b[1m结果: ${pass} 通过 / ${fail} 失败\x1b[0m`) if (fail) { console.log('\n失败用例:') for (const r of results.filter(x => !x.ok)) console.log(` - ${r.name}\n ${r.detail}`) } process.exit(fail ? 1 : 0) } main().catch((e) => { console.error('自测脚本异常:', e) process.exit(2) })