@hyzyn/dsh-tty 0.18.3 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,593 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @hyzyn/dsh-tty — SSH 冒烟:内存 SSH server(ssh2.Server)× 真实 spawnSsh 端到端。
4
+ *
5
+ * 本机不依赖真实 sshd —— 用 ssh2 的 Server 在 127.0.0.1 随机端口起一个内存 SSH
6
+ * 服务端(临时 RSA host key,crypto.generateKeyPairSync 导出 pkcs1 PEM),只放行
7
+ * 密码用户 test/secret。shell 回调实现一个微型伪 shell:
8
+ * - 连上先发送 prompt;
9
+ * - 收到 `printf "X%s\n" OK` / `echo X` 时返回可匹配文本(不回显输入行,匹配零歧义);
10
+ * - 处理 `stty size`(返回 "rows cols"),验证 pty-req 初始尺寸与 resize 后的
11
+ * window-change 真正到达服务端(同 channel 顺序投递,resize 后紧跟的 stty
12
+ * 必然读到新尺寸);
13
+ * - `exit` 回送 exit-status 后关 channel。
14
+ * 注意:客户端 conn.shell({term,cols,rows}) 会先发 pty-req,ssh2 服务端在没有
15
+ * 'pty' 监听时会「自动拒绝」该请求导致 shell 开不起来 —— 必须接住并 accept。
16
+ *
17
+ * 然后用构建产物 ../lib/ssh.js 的 spawnSsh 以 auth=password 连上来,验证:
18
+ * S1 password 认证 + shell channel 建立,prompt 经输出流可见
19
+ * S2 printf / echo 命令输出可匹配(channel 数据往返)
20
+ * S3 stty size 返回 pty-req 初始尺寸(24x80 → "24 80")
21
+ * S4 resize(110,33) 不抛错且生效(window-change → stty "33 110")
22
+ * S5 terminate 后 done 恰好 resolve(含退出事实)
23
+ * S6 第二轮会话 exit 命令 → 服务端 exit-status → done.exitCode=0
24
+ * S7 TOFU:首次连接经 HostKeyStore 记录 sha256 指纹
25
+ * S8 TOFU:同 host:port 换服务器密钥后,指纹变更拒绝连接(带重置指引)
26
+ * S9 SFTP(0.7.0):scripts/lib/test-sshd.mjs 的 sftp subsystem(映射临时目录
27
+ * 真实 fs)× 真实 SftpManager——list/realpath home/上传覆盖+追加/下载
28
+ * (含 content-length)/mkdir+rename+递归与非递归删除/TOFU 指纹变更拒绝;
29
+ * S9g(0.8.0):mkdir parents 自底向上逐级补齐(幂等)+ tree 限深截断/全量
30
+ *
31
+ * 用法:pnpm --filter @hyzyn/dsh-tty ssh-smoke(需先 build 产出 lib/ssh.js、lib/sftp.js)
32
+ * 退出码:0 = 全部 PASS,1 = 任一 FAIL。
33
+ */
34
+ import ssh2 from 'ssh2'
35
+ import { generateKeyPairSync } from 'node:crypto'
36
+ import fsp from 'node:fs/promises'
37
+ import os from 'node:os'
38
+ import path from 'node:path'
39
+ import { spawnSsh } from '../lib/ssh.js'
40
+ import { buildRemoteStatsCommand } from '../lib/stats.js'
41
+ import { SftpManager } from '../lib/sftp.js'
42
+ import { startSftpSshd, TEST_USER, TEST_PASSWORD } from './lib/test-sshd.mjs'
43
+
44
+ const USER = 'test'
45
+ const PASSWORD = 'secret'
46
+ const PROMPT = 'dsh-ssh-smoke:~$ '
47
+
48
+ /* 全局看门狗:任何环节卡死时留痕退出(正常路径会先 process.exit)。 */
49
+ setTimeout(() => {
50
+ console.error('[watchdog] 90s 看门狗触发:ssh-smoke 卡死')
51
+ process.exit(2)
52
+ }, 90000).unref()
53
+ process.on('unhandledRejection', (reason) => {
54
+ console.error('[unhandledRejection]', reason)
55
+ })
56
+
57
+ /** 内存 sshd 收到的 exec 记录(S10 验证:确为非 PTY、命令经 sh -c 单引号包裹)。 */
58
+ const EXEC_LOG = []
59
+ const RESULTS = []
60
+ function pass(name) { RESULTS.push(['PASS', name]); console.log(' ✔ PASS ' + name) }
61
+ function fail(name, detail) { RESULTS.push(['FAIL', name, detail]); console.error(' ✘ FAIL ' + name + (detail ? ' — ' + detail : '')) }
62
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
63
+
64
+ /** 累积终端输出直到匹配正则,超时 reject(先建订阅再写输入,无竞态窗口)。 */
65
+ function expectOutput(handle, re, timeoutMs = 8000, label = '输出') {
66
+ return new Promise((resolve, reject) => {
67
+ let buffer = ''
68
+ const onData = (chunk) => {
69
+ buffer += chunk.toString('utf8')
70
+ const match = buffer.match(re)
71
+ if (match) {
72
+ clearTimeout(timer)
73
+ handle.output.off('data', onData)
74
+ resolve(match)
75
+ }
76
+ }
77
+ const timer = setTimeout(() => {
78
+ handle.output.off('data', onData)
79
+ reject(new Error(`等待「${label}」超时;尾部输出: ${JSON.stringify(buffer.slice(-200))}`))
80
+ }, timeoutMs)
81
+ handle.output.on('data', onData)
82
+ })
83
+ }
84
+
85
+ /** done 竞速:超时给出可读 FAIL 而不是等看门狗。 */
86
+ function expectDone(handle, timeoutMs = 10000) {
87
+ return Promise.race([
88
+ handle.done,
89
+ new Promise((_, reject) => setTimeout(() => reject(new Error('done 未在 ' + timeoutMs + 'ms 内 resolve')), timeoutMs)),
90
+ ])
91
+ }
92
+
93
+ /* ------------------------------------------------------------------ *
94
+ * 内存 SSH server(伪 shell)
95
+ * ------------------------------------------------------------------ */
96
+
97
+ /** 把一行命令翻译成响应;返回 true 表示 shell 应当结束(exit)。 */
98
+ function respondTo(stream, cmd, dims) {
99
+ // stty size → "rows cols"(与真实 stty 输出格式一致)
100
+ if (/^stty\s+size$/.test(cmd)) {
101
+ stream.write(`${dims.rows} ${dims.cols}\r\n`)
102
+ return false
103
+ }
104
+ // printf "FMT" [args…]:只支持 %s 占位符逐个吃参数(冒烟所需最小子集)
105
+ const printf = cmd.match(/^printf\s+(["'])(.*?)\1(?:\s+(.*))?$/)
106
+ if (printf) {
107
+ const args = printf[3] === undefined ? [] : printf[3].split(/\s+/)
108
+ let i = 0
109
+ const text = printf[2].replace(/%s/g, () => args[i++] ?? '')
110
+ stream.write(text.replace(/\\n/g, '\r\n') + '\r\n')
111
+ return false
112
+ }
113
+ // echo [args…]
114
+ const echo = cmd.match(/^echo\s+(.+?)\s*$/)
115
+ if (echo) {
116
+ const quoted = echo[1].match(/^(["'])(.*)\1$/)
117
+ stream.write((quoted ? quoted[2] : echo[1].split(/\s+/).join(' ')) + '\r\n')
118
+ return false
119
+ }
120
+ stream.write(`ssh-smoke: command not found: ${cmd}\r\n`)
121
+ return false
122
+ }
123
+
124
+ function onClientConnection(client) {
125
+ client.on('authentication', (ctx) => {
126
+ if (ctx.method === 'password' && ctx.username === USER && ctx.password === PASSWORD) {
127
+ ctx.accept()
128
+ return
129
+ }
130
+ ctx.reject()
131
+ })
132
+ client.on('ready', () => {
133
+ client.on('session', (accept) => {
134
+ const session = accept()
135
+ // 终端尺寸:pty-req 给初值,window-change 跟进(resize 验证的数据源)
136
+ const dims = { rows: 24, cols: 80 }
137
+ let ptySeen = false
138
+ session.on('pty', (acceptPty, _rejectPty, info) => {
139
+ ptySeen = true
140
+ if (Number.isFinite(info?.rows)) dims.rows = info.rows
141
+ if (Number.isFinite(info?.cols)) dims.cols = info.cols
142
+ acceptPty()
143
+ })
144
+ session.on('window-change', (_accept, _reject, info) => {
145
+ if (Number.isFinite(info?.rows)) dims.rows = info.rows
146
+ if (Number.isFinite(info?.cols)) dims.cols = info.cols
147
+ })
148
+ // 服务器状态条(0.17.0):非 PTY 的 exec channel。真实远端脚本见
149
+ // src/stats.ts 的 remoteStatsScript;这里按同一输出形状回吐 JSON 行,
150
+ // 供冒烟验证「同连接另开 channel + 逐行解析 + stop 关 channel」。
151
+ session.on('exec', (acceptExec, _rejectExec, info) => {
152
+ const command = typeof info?.command === 'string' ? info.command : ''
153
+ EXEC_LOG.push({ command, pty: ptySeen })
154
+ if (!command.includes('/proc/stat')) {
155
+ const stream = acceptExec()
156
+ stream.exit(127)
157
+ stream.end()
158
+ return
159
+ }
160
+ const stream = acceptExec()
161
+ let tick = 0
162
+ const timer = setInterval(() => {
163
+ tick += 1
164
+ stream.write(JSON.stringify({
165
+ cpuPct: 10 + tick, cores: 8, memUsed: 1e9 * tick, memTotal: 8e9, memPct: 12.5 * tick,
166
+ diskTotal: 1e11, diskUsed: 5e10, diskPct: 50, uptimeSec: 3600 * tick,
167
+ tcpConns: 100 + tick, rxRate: 1024 * tick, txRate: 2048 * tick, tempC: 45.5,
168
+ }) + '\n')
169
+ }, 40)
170
+ const stop = () => clearInterval(timer)
171
+ stream.on('close', stop)
172
+ stream.on('end', stop)
173
+ })
174
+ session.on('shell', (acceptShell) => {
175
+ const stream = acceptShell()
176
+ stream.write(PROMPT)
177
+ let line = ''
178
+ stream.on('data', (chunk) => {
179
+ for (const ch of chunk.toString('utf8')) {
180
+ if (ch === '\r' || ch === '\n') {
181
+ const cmd = line.trim()
182
+ line = ''
183
+ if (cmd === 'exit' || cmd === 'logout') {
184
+ // 先 exit-status 再 eof+close:客户端 channel 依次收到
185
+ // exit(exitCode=0) 与 close → done resolve
186
+ stream.exit(0)
187
+ stream.end()
188
+ return
189
+ }
190
+ if (cmd !== '') respondTo(stream, cmd, dims)
191
+ stream.write(PROMPT)
192
+ } else if (ch === '\x7f') {
193
+ line = line.slice(0, -1) // 退格
194
+ } else if (ch >= ' ') {
195
+ line += ch
196
+ }
197
+ }
198
+ })
199
+ })
200
+ })
201
+ })
202
+ client.on('error', (error) => {
203
+ console.error('[ssh-smoke] 服务端 client 连接错误: ' + error.message)
204
+ })
205
+ }
206
+
207
+ async function startServer(preferredPort) {
208
+ // 临时 RSA host key:pkcs1 PEM(-----BEGIN RSA PRIVATE KEY-----),不落盘。
209
+ // 传 preferredPort 时复用同一端口(TOFU 用例:同 host:port 换密钥模拟指纹变更)
210
+ const { privateKey } = generateKeyPairSync('rsa', {
211
+ modulusLength: 2048,
212
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
213
+ privateKeyEncoding: { type: 'pkcs1', format: 'pem' },
214
+ })
215
+ let lastError = null
216
+ for (let attempt = 0; attempt < 10; attempt++) {
217
+ const server = new ssh2.Server({ hostKeys: [privateKey] }, onClientConnection)
218
+ try {
219
+ await new Promise((resolve, reject) => {
220
+ server.once('error', reject)
221
+ server.listen(preferredPort ?? 0, '127.0.0.1', () => {
222
+ server.removeListener('error', reject)
223
+ resolve()
224
+ })
225
+ })
226
+ server.on('error', (error) => {
227
+ console.error('[ssh-smoke] server 错误: ' + error.message)
228
+ })
229
+ return server
230
+ } catch (error) {
231
+ lastError = error
232
+ await sleep(300) // 端口尚未完全释放(TIME_WAIT 等):稍后重试
233
+ }
234
+ }
235
+ throw lastError ?? new Error('startServer 重试耗尽')
236
+ }
237
+
238
+ async function stopServer(server) {
239
+ await new Promise((resolve) => {
240
+ const timer = setTimeout(resolve, 3000).unref() // close 回调兜底,避免清理环节卡死
241
+ server.close(() => {
242
+ clearTimeout(timer)
243
+ resolve()
244
+ })
245
+ })
246
+ server.closeAllConnections?.()
247
+ }
248
+
249
+ /* ------------------------------------------------------------------ *
250
+ * 主流程
251
+ * ------------------------------------------------------------------ */
252
+
253
+ async function runSession(server, port, options, label) {
254
+ console.log(`\n[${label}] spawnSsh → 127.0.0.1:${port}(auth=password, ${USER}/${PASSWORD})`)
255
+ const handle = await spawnSsh(
256
+ { host: '127.0.0.1', port, username: USER, auth: 'password', password: PASSWORD },
257
+ options,
258
+ )
259
+ return handle
260
+ }
261
+
262
+ async function main() {
263
+ console.log('启动内存 SSH server(ssh2.Server @ 127.0.0.1 随机端口)…')
264
+ const server = await startServer()
265
+ const port = server.address().port
266
+ console.log(`server 就绪 on 127.0.0.1:${port},放行密码用户 ${USER}`)
267
+
268
+ // TOFU 指纹存储(内存实现):S1/S6 走 record + match,S8 用换密钥的服务端走 mismatch
269
+ const fingerprintMap = new Map()
270
+ const hostKeyStore = {
271
+ // 0.19.0 起 get 返回指纹集合(一机多把钥匙)
272
+ get: (host, p) => {
273
+ const fp = fingerprintMap.get(host + ':' + p)
274
+ return fp === undefined ? undefined : [fp]
275
+ },
276
+ record: (host, p, fp) => fingerprintMap.set(host + ':' + p, fp),
277
+ }
278
+ const options = { term: 'xterm-256color', cols: 80, rows: 24, hostKeyStore, logger: { info: (m) => console.log(' ' + m), warn: (m) => console.warn(' ' + m) } }
279
+
280
+ // ---- 会话 1:认证/输出/resize/terminate ----
281
+ console.log('\n[1] 全链路(spawn→prompt→printf/echo→stty→resize→terminate)')
282
+ {
283
+ let handle
284
+ try {
285
+ handle = await runSession(server, port, options, '1')
286
+ } catch (error) {
287
+ fail('S1 password 认证 + shell channel 建立(prompt 可见)', error.message)
288
+ throw error
289
+ }
290
+ try {
291
+ await expectOutput(handle, /dsh-ssh-smoke:~\$ /, 8000, 'prompt')
292
+ pass('S1 password 认证 + shell channel 建立(prompt 经输出流可见)')
293
+ } catch (error) {
294
+ fail('S1 password 认证 + shell channel 建立(prompt 可见)', error.message)
295
+ }
296
+
297
+ // printf(先建订阅再写输入,无竞态窗口)
298
+ try {
299
+ const pending = expectOutput(handle, /SSH_SMOKE_OK/, 8000, 'printf 输出')
300
+ await handle.write('printf "SSH_SMOKE_%s\\n" OK\n')
301
+ await pending
302
+ pass('S2a printf 命令输出可匹配(channel 数据往返)')
303
+ } catch (error) {
304
+ fail('S2a printf 命令输出可匹配(channel 数据往返)', error.message)
305
+ }
306
+ // echo
307
+ try {
308
+ const pending = expectOutput(handle, /SMOKE_ECHO_OK/, 8000, 'echo 输出')
309
+ await handle.write('echo SMOKE_ECHO_OK\n')
310
+ await pending
311
+ pass('S2b echo 命令输出可匹配')
312
+ } catch (error) {
313
+ fail('S2b echo 命令输出可匹配', error.message)
314
+ }
315
+
316
+ // stty size:pty-req 初始尺寸(rows cols = "24 80")
317
+ try {
318
+ const pending = expectOutput(handle, /(\d+)\s+(\d+)/, 8000, 'stty 初始尺寸')
319
+ await handle.write('stty size\n')
320
+ const m = await pending
321
+ const ok = Number(m[1]) === 24 && Number(m[2]) === 80
322
+ console.log(` stty size → ${m[1]} ${m[2]}(期望 24 80)`)
323
+ if (ok) pass('S3 stty size 命中 pty-req 初始尺寸(rows=24 cols=80)')
324
+ else fail('S3 stty size 命中 pty-req 初始尺寸(rows=24 cols=80)', `得到 ${m[1]} ${m[2]}`)
325
+ } catch (error) {
326
+ fail('S3 stty size 命中 pty-req 初始尺寸(rows=24 cols=80)', error.message)
327
+ }
328
+
329
+ // resize:不抛错 + window-change 生效(同 channel 顺序投递,紧跟的 stty 必读到新尺寸)
330
+ try {
331
+ let threw = null
332
+ try { handle.resize(110, 33) } catch (error) { threw = error }
333
+ if (threw !== null) fail('S4 resize(110,33) 不抛错', threw.message)
334
+ else {
335
+ await sleep(100)
336
+ const pending = expectOutput(handle, /(\d+)\s+(\d+)/, 8000, 'stty resize 后尺寸')
337
+ await handle.write('stty size\n')
338
+ const m = await pending
339
+ const ok = Number(m[1]) === 33 && Number(m[2]) === 110
340
+ console.log(` resize(110,33) 后 stty size → ${m[1]} ${m[2]}(期望 33 110)`)
341
+ if (ok) pass('S4 resize(110,33) 不抛错且 window-change 生效')
342
+ else fail('S4 resize(110,33) 不抛错且 window-change 生效', `stty 报告 ${m[1]} ${m[2]}`)
343
+ }
344
+ } catch (error) {
345
+ fail('S4 resize(110,33) 不抛错且 window-change 生效', error.message)
346
+ }
347
+
348
+ // terminate → done resolve(此时 channel 未自然退出,done 由 terminate 触发)
349
+ try {
350
+ let threw = null
351
+ try { await handle.terminate() } catch (error) { threw = error }
352
+ if (threw !== null) throw threw
353
+ const outcome = await expectDone(handle)
354
+ console.log(` terminate → done resolve(exitCode=${outcome.exitCode} signal=${outcome.signal})`)
355
+ pass('S5 terminate 后 done resolve(含退出事实)')
356
+ } catch (error) {
357
+ fail('S5 terminate 后 done resolve(含退出事实)', error.message)
358
+ }
359
+ }
360
+
361
+ // ---- 会话 2:exit 命令 → 服务端 exit-status → done.exitCode=0 ----
362
+ console.log('\n[2] exit 命令自然退出(exit-status 路径)')
363
+ {
364
+ let handle
365
+ try {
366
+ handle = await runSession(server, port, options, '2')
367
+ await expectOutput(handle, /dsh-ssh-smoke:~\$ /, 8000, 'prompt')
368
+ const pending = expectDone(handle)
369
+ await handle.write('exit\n')
370
+ const outcome = await pending
371
+ console.log(` exit → done resolve(exitCode=${outcome.exitCode} signal=${outcome.signal})`)
372
+ if (outcome.exitCode === 0) pass('S6 exit 命令 → 服务端 exit-status → done.exitCode=0')
373
+ else fail('S6 exit 命令 → 服务端 exit-status → done.exitCode=0', `exitCode=${outcome.exitCode}`)
374
+ } catch (error) {
375
+ fail('S6 exit 命令 → 服务端 exit-status → done.exitCode=0', error.message)
376
+ }
377
+ }
378
+
379
+ // ---- TOFU 主机指纹钉扎(协议层)----
380
+ console.log('\n[3] TOFU 主机指纹钉扎')
381
+ {
382
+ // S7:前两轮连接应已在 HostKeyStore 记录指纹
383
+ const fp = hostKeyStore.get('127.0.0.1', port)
384
+ if (Array.isArray(fp) && typeof fp[0] === 'string' && fp[0].length > 0) pass('S7 首次连接经 HostKeyStore 记录 sha256 指纹(' + fp[0].slice(0, 16) + '…)')
385
+ else fail('S7 首次连接经 HostKeyStore 记录 sha256 指纹', String(fp))
386
+
387
+ // S8:同 host:port 换一把服务器密钥 → 指纹变更必须拒绝连接
388
+ await stopServer(server)
389
+ const server2 = await startServer(port)
390
+ console.log(` 已在 127.0.0.1:${port} 起新 server(不同 host key),验证指纹变更拒绝…`)
391
+ let mismatchError = null
392
+ try {
393
+ await spawnSsh(
394
+ { host: '127.0.0.1', port, username: USER, auth: 'password', password: PASSWORD },
395
+ options,
396
+ )
397
+ } catch (error) {
398
+ mismatchError = error instanceof Error ? error.message : String(error)
399
+ }
400
+ if (mismatchError !== null && /主机密钥指纹变更/.test(mismatchError)) pass('S8 指纹变更拒绝连接(错误含重置指引)')
401
+ else fail('S8 指纹变更拒绝连接(错误含重置指引)', mismatchError === null ? '连接意外成功' : String(mismatchError))
402
+
403
+ await stopServer(server2)
404
+ console.log('两个内存 SSH server 均已关闭(端口已释放)')
405
+ }
406
+
407
+ // ---- SFTP(0.7.0):test-sshd 的 sftp subsystem × 真实 SftpManager ----
408
+ console.log('\n[4] SFTP 文件传输(test-sshd sftp subsystem × lib/sftp.js)')
409
+ {
410
+ const rootDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'dsh-tty-sftp-'))
411
+ const sftpd = await startSftpSshd({ rootDir })
412
+ console.log(` sftp sshd on 127.0.0.1:${sftpd.port},rootDir=${rootDir}`)
413
+ await fsp.writeFile(path.join(rootDir, 'a.txt'), Buffer.alloc(100, 0x61))
414
+ await fsp.mkdir(path.join(rootDir, 'sub'))
415
+ await fsp.writeFile(path.join(rootDir, 'sub', 'nested.txt'), 'nested')
416
+
417
+ const sftpLogger = { info: () => {}, warn: (m) => console.warn(' ' + m) }
418
+ const acceptStore = { get: () => undefined, record: () => {} }
419
+ const manager = new SftpManager(sftpLogger, acceptStore)
420
+ const spec = { host: '127.0.0.1', port: sftpd.port, username: TEST_USER, auth: 'password', password: TEST_PASSWORD }
421
+
422
+ // S9a 目录列表:真实路径、文件/目录判定与 size
423
+ try {
424
+ const listed = await manager.list(spec, rootDir)
425
+ const aTxt = listed.entries.find((e) => e.name === 'a.txt')
426
+ const sub = listed.entries.find((e) => e.name === 'sub')
427
+ const ok = listed.path === rootDir
428
+ && Array.isArray(listed.entries) && listed.entries.length === 2
429
+ && aTxt?.isFile === true && aTxt?.size === 100
430
+ && sub?.isDir === true && sub?.isFile === false
431
+ if (ok) pass('S9a list 目录列表(真实路径、文件/目录判定与 size)')
432
+ else fail('S9a list 目录列表(真实路径、文件/目录判定与 size)', JSON.stringify(listed).slice(0, 200))
433
+ } catch (error) {
434
+ fail('S9a list 目录列表(真实路径、文件/目录判定与 size)', error.message)
435
+ }
436
+
437
+ // S9b path 缺省 → realpath('.') 解析到登录 home(test-sshd 映射为 rootDir)
438
+ try {
439
+ const home = await manager.list(spec, '')
440
+ if (home.path === rootDir) pass('S9b path 缺省 realpath 解析到登录 home')
441
+ else fail('S9b path 缺省 realpath 解析到登录 home', 'path=' + String(home.path))
442
+ } catch (error) {
443
+ fail('S9b path 缺省 realpath 解析到登录 home', error.message)
444
+ }
445
+
446
+ // S9c 上传:覆盖写 + 追加写,字节逐一比对
447
+ try {
448
+ const data = Buffer.alloc(5000, 0x5a)
449
+ const first = await manager.openUpload(spec, rootDir + '/up.bin')
450
+ first.stream.write(data)
451
+ first.stream.end()
452
+ await first.done
453
+ const second = await manager.openUpload(spec, rootDir + '/up.bin', true)
454
+ second.stream.write('tail')
455
+ second.stream.end()
456
+ await second.done
457
+ const expected = Buffer.concat([data, Buffer.from('tail')])
458
+ const stored = await fsp.readFile(path.join(rootDir, 'up.bin'))
459
+ if (stored.equals(expected)) pass('S9c 上传覆盖 + 追加(5000+4 字节逐一一致)')
460
+ else fail('S9c 上传覆盖 + 追加(5000+4 字节逐一一致)', `落盘 ${String(stored.length)} 字节,期望 ${String(expected.length)}`)
461
+ } catch (error) {
462
+ fail('S9c 上传覆盖 + 追加(5000+4 字节逐一一致)', error.message)
463
+ }
464
+
465
+ // S9d 下载:字节一致 + size(content-length 数据源)
466
+ try {
467
+ const { stream, size } = await manager.openDownload(spec, rootDir + '/up.bin')
468
+ const chunks = []
469
+ for await (const chunk of stream) chunks.push(chunk)
470
+ const downloaded = Buffer.concat(chunks)
471
+ const expected = Buffer.concat([Buffer.alloc(5000, 0x5a), Buffer.from('tail')])
472
+ if (downloaded.equals(expected) && size === expected.length) {
473
+ pass('S9d 下载字节一致(stat size=' + String(size) + ' = 实际字节数)')
474
+ } else {
475
+ fail('S9d 下载字节一致(stat size 与实际一致)', `size=${String(size)} 实际=${String(downloaded.length)}`)
476
+ }
477
+ } catch (error) {
478
+ fail('S9d 下载字节一致(stat size 与实际一致)', error.message)
479
+ }
480
+
481
+ // S9e mkdir / rename / 非递归删除拒绝 / 递归删除
482
+ try {
483
+ await manager.mkdir(spec, rootDir + '/nested')
484
+ await manager.rename(spec, rootDir + '/a.txt', rootDir + '/nested/b.txt')
485
+ const moved = await fsp.access(path.join(rootDir, 'nested', 'b.txt')).then(() => true, () => false)
486
+ let refused = null
487
+ try { await manager.remove(spec, rootDir + '/nested', false) } catch (error) { refused = error.message }
488
+ const refusedOk = refused !== null && /recursive|非空/.test(refused)
489
+ await manager.remove(spec, rootDir + '/nested', true)
490
+ const gone = await fsp.access(path.join(rootDir, 'nested')).then(() => false, () => true)
491
+ if (moved && refusedOk && gone) pass('S9e mkdir/rename/非递归删除拒绝/递归删除')
492
+ else fail('S9e mkdir/rename/非递归删除拒绝/递归删除', `moved=${String(moved)} refused=${JSON.stringify(refused)} gone=${String(gone)}`)
493
+ } catch (error) {
494
+ fail('S9e mkdir/rename/非递归删除拒绝/递归删除', error.message)
495
+ }
496
+
497
+ // S9f TOFU:预置指纹与服务器不符 → SFTP 连接同样拒绝(与终端同源钉扎)
498
+ try {
499
+ const mismatchMap = new Map([[`127.0.0.1:${String(sftpd.port)}`, 'deadbeef'.repeat(8)]])
500
+ const strictStore = { get: (host, p) => { const fp = mismatchMap.get(host + ':' + String(p)); return fp === undefined ? undefined : [fp] }, record: () => {} }
501
+ const strictManager = new SftpManager(sftpLogger, strictStore)
502
+ let mismatch = null
503
+ try { await strictManager.list(spec, '') } catch (error) { mismatch = error.message }
504
+ strictManager.disposeAll()
505
+ if (mismatch !== null && /主机密钥指纹变更/.test(mismatch)) pass('S9f TOFU 指纹变更拒绝 SFTP 连接(错误含重置指引)')
506
+ else fail('S9f TOFU 指纹变更拒绝 SFTP 连接(错误含重置指引)', mismatch === null ? '连接意外成功' : String(mismatch))
507
+ } catch (error) {
508
+ fail('S9f TOFU 指纹变更拒绝 SFTP 连接(错误含重置指引)', error.message)
509
+ }
510
+
511
+ // S9g mkdir parents 自底向上逐级补齐(mkdir -p 语义)+ tree 递归列举与限深
512
+ try {
513
+ const deep = rootDir + '/p/a/b/c'
514
+ await manager.mkdir(spec, deep, true)
515
+ await manager.mkdir(spec, rootDir + '/p', true) // 幂等:已存在目录不报错
516
+ await fsp.writeFile(path.join(rootDir, 'p', 'a', 'b', 'c', 'leaf.txt'), 'leaf\n')
517
+ const shallow = await manager.tree(spec, rootDir + '/p', { maxDepth: 1 })
518
+ const full = await manager.tree(spec, rootDir + '/p', { maxDepth: 4 })
519
+ const shallowOk = shallow.entries.length === 1 && shallow.entries[0].path === rootDir + '/p/a'
520
+ && shallow.entries[0].isDir === true && shallow.truncated === true
521
+ const fullOk = full.entries.map((e) => e.path).join('|') === [rootDir + '/p/a', rootDir + '/p/a/b', rootDir + '/p/a/b/c', rootDir + '/p/a/b/c/leaf.txt'].join('|')
522
+ && full.truncated === false
523
+ && full.entries[3].size === 'leaf\n'.length
524
+ let noParentsRejected = false
525
+ try { await manager.mkdir(spec, rootDir + '/q/r/s') } catch { noParentsRejected = true }
526
+ await manager.remove(spec, rootDir + '/p', true)
527
+ const cleaned = await fsp.access(path.join(rootDir, 'p')).then(() => false, () => true)
528
+ if (shallowOk && fullOk && noParentsRejected && cleaned) pass('S9g mkdir parents 逐级补齐/幂等 + tree 限深截断/全量')
529
+ else fail('S9g mkdir parents 逐级补齐/幂等 + tree 限深截断/全量', `shallow=${JSON.stringify(shallow.entries).slice(0, 100)} truncated=${String(shallow.truncated)} full=${full.entries.map((e) => e.path).join('|')} noParents=${String(noParentsRejected)} cleaned=${String(cleaned)}`)
530
+ } catch (error) {
531
+ fail('S9g mkdir parents 逐级补齐/幂等 + tree 限深截断/全量', error.message)
532
+ }
533
+
534
+ manager.disposeAll()
535
+ await sftpd.close()
536
+ await fsp.rm(rootDir, { recursive: true, force: true })
537
+ console.log(' sftp sshd 已关闭,临时目录已清理')
538
+ }
539
+
540
+ // ---- S10:服务器状态条(非 PTY exec channel + 逐行 JSON + stop 收尾)----
541
+ console.log('\n[10] 服务器状态条:同连接另开 exec channel')
542
+ {
543
+ // S8 已把前面那台 server 关掉(换密钥用例),这里另起一台(TOFU 接受式 store)
544
+ const statsServer = await startServer()
545
+ const statsPort = statsServer.address().port
546
+ const statsOptions = { ...options, hostKeyStore: { get: () => undefined, record: () => {} } }
547
+ let handle
548
+ try {
549
+ handle = await runSession(statsServer, statsPort, statsOptions, '10')
550
+ await expectOutput(handle, /dsh-ssh-smoke:~\$ /, 8000, 'prompt')
551
+ if (typeof handle.statsExec !== 'function') {
552
+ fail('S10 handle 暴露 statsExec(非 PTY exec channel)', 'statsExec 缺失')
553
+ } else {
554
+ const lines = []
555
+ let errors = 0
556
+ const collector = handle.statsExec(buildRemoteStatsCommand(), (line) => lines.push(line), () => { errors += 1 })
557
+ const deadline = Date.now() + 5000
558
+ while (lines.length < 2 && Date.now() < deadline) await sleep(50)
559
+ const entry = EXEC_LOG[EXEC_LOG.length - 1]
560
+ const nonPty = entry !== undefined && entry.pty === false
561
+ const wrapped = entry !== undefined && entry.command.startsWith('sh -c ') && entry.command.includes('/proc/stat')
562
+ if (lines.length >= 2 && nonPty && wrapped) pass('S10 独立 exec channel(无 PTY)+ 逐行 JSON:' + String(lines.length) + ' 行')
563
+ else fail('S10 独立 exec channel(无 PTY)+ 逐行 JSON', 'lines=' + String(lines.length) + ' nonPty=' + String(nonPty) + ' wrapped=' + String(wrapped) + ' cmd=' + String(entry && entry.command).slice(0, 60))
564
+ // stop() 关 channel → 远端循环结束(不再有新行),且不误报 onError
565
+ collector.stop()
566
+ await sleep(120)
567
+ const mark = lines.length
568
+ await sleep(300)
569
+ if (lines.length === mark) pass('S10b stop() 关闭 channel,远端不再产出行')
570
+ else fail('S10b stop() 关闭 channel,远端不再产出行', 'stop 后又收到 ' + String(lines.length - mark) + ' 行')
571
+ if (errors === 0) pass('S10c 主动 stop 不触发 onError(不算采集失败)')
572
+ else fail('S10c 主动 stop 不触发 onError', 'errors=' + String(errors))
573
+ }
574
+ } catch (error) {
575
+ fail('S10 服务器状态条 exec channel', error.message)
576
+ } finally {
577
+ try { await handle?.terminate() } catch { /* 忽略 */ }
578
+ await stopServer(statsServer)
579
+ }
580
+ }
581
+
582
+ {
583
+ const failed = RESULTS.filter(([kind]) => kind === 'FAIL')
584
+ console.log(`\n==== SSH 冒烟:${RESULTS.length - failed.length}/${RESULTS.length} PASS ====`)
585
+ for (const [kind, name, detail] of RESULTS) console.log(` ${kind === 'PASS' ? '✔' : '✘'} ${name}${detail ? ' — ' + detail : ''}`)
586
+ process.exit(failed.length > 0 ? 1 : 0)
587
+ }
588
+ }
589
+
590
+ main().catch((error) => {
591
+ console.error('ssh-smoke 崩溃:', error)
592
+ process.exit(1)
593
+ })