'use strict' const test = require('node:test') const assert = require('node:assert/strict') const fs = require('fs') const os = require('os') const path = require('path') const { sanitizeAttachmentName, isValidHostKey, isValidSessionId, resolveAttachmentTarget, uniqueName, copyLocalAttachment, copyLocalAttachmentAsync, writeLocalTextFileAsync, MAIN_PROCESS_MAX_SIZE_MB, } = require('./chatAttachment.cjs') // ─── sanitizeAttachmentName ─── test('sanitizeAttachmentName basename 化与遍历字符', () => { assert.equal(sanitizeAttachmentName('../../x'), 'x') assert.equal(sanitizeAttachmentName('a/b.txt'), 'b.txt') assert.equal(sanitizeAttachmentName('C:\\dir\\sub\\x.txt'), 'x.txt') assert.equal(sanitizeAttachmentName('..\\..\\y.log'), 'y.log') }) test('sanitizeAttachmentName 空串 / . / .. 回落 attachment', () => { assert.equal(sanitizeAttachmentName(''), 'attachment') assert.equal(sanitizeAttachmentName('.'), 'attachment') assert.equal(sanitizeAttachmentName('..'), 'attachment') assert.equal(sanitizeAttachmentName('/'), 'attachment') assert.equal(sanitizeAttachmentName('...'), 'attachment') assert.equal(sanitizeAttachmentName(null), 'attachment') assert.equal(sanitizeAttachmentName(undefined), 'attachment') }) test('sanitizeAttachmentName 剥控制字符与首尾点/空格', () => { assert.equal(sanitizeAttachmentName('a\tb.txt'), 'ab.txt') assert.equal(sanitizeAttachmentName('a\nb\r.txt'), 'ab.txt') assert.equal(sanitizeAttachmentName('file.'), 'file') assert.equal(sanitizeAttachmentName('file .txt'), 'file .txt') assert.equal(sanitizeAttachmentName('.hidden'), 'hidden') assert.equal(sanitizeAttachmentName('x.txt '), 'x.txt') }) test('sanitizeAttachmentName Windows 保留名加 _ 前缀', () => { assert.equal(sanitizeAttachmentName('CON.txt'), '_CON.txt') assert.equal(sanitizeAttachmentName('con'), '_con') assert.equal(sanitizeAttachmentName('PRN'), '_PRN') assert.equal(sanitizeAttachmentName('aux.log'), '_aux.log') assert.equal(sanitizeAttachmentName('NUL'), '_NUL') assert.equal(sanitizeAttachmentName('COM1'), '_COM1') assert.equal(sanitizeAttachmentName('lpt9.dat'), '_lpt9.dat') // 普通名不受影响 assert.equal(sanitizeAttachmentName('CONSOLE.txt'), 'CONSOLE.txt') assert.equal(sanitizeAttachmentName('com10.txt'), 'com10.txt') }) test('sanitizeAttachmentName 超长截断保留扩展名且 ≤100', () => { const longStem = 'a'.repeat(200) const name = sanitizeAttachmentName(`${longStem}.txt`) assert.equal(name, `${'a'.repeat(96)}.txt`) assert.ok(name.length <= 100) // 无扩展名同样截断 const noExt = sanitizeAttachmentName('b'.repeat(150)) assert.equal(noExt, 'b'.repeat(100)) // 超长扩展名:整体截断 const longExt = sanitizeAttachmentName(`c.${'d'.repeat(200)}`) assert.ok(longExt.length <= 100) // 幂等 assert.equal(sanitizeAttachmentName(name), name) }) test('sanitizeAttachmentName 全角点等异常字符不逃逸', () => { // 全角点不是路径分隔符,保留即可(落盘是单段 basename,无逃逸风险) assert.equal(sanitizeAttachmentName('a.b.txt'), 'a.b.txt') // 截断后再 strip 仍安全 const name = sanitizeAttachmentName(`${'e'.repeat(99)}.`) assert.ok(name.length <= 100) assert.ok(!name.endsWith('.')) }) // ─── hostKey / sessionId 校验 ─── test('isValidHostKey 拒绝注入形态', () => { assert.equal(isValidHostKey('mao.etarch.cn'), true) assert.equal(isValidHostKey('10.0.0.8_9080'), true) assert.equal(isValidHostKey(''), false) assert.equal(isValidHostKey('a/b'), false) assert.equal(isValidHostKey('a\\b'), false) assert.equal(isValidHostKey('..'), false) assert.equal(isValidHostKey('.'), false) assert.equal(isValidHostKey(' host'), false) assert.equal(isValidHostKey('host\n'), false) assert.equal(isValidHostKey(null), false) }) test('isValidSessionId 只接受正整数', () => { assert.equal(isValidSessionId(1), true) assert.equal(isValidSessionId('42'), true) assert.equal(isValidSessionId(' 42 '), true) assert.equal(isValidSessionId(0), false) assert.equal(isValidSessionId(-1), false) assert.equal(isValidSessionId('1/../../x'), false) assert.equal(isValidSessionId('1.5'), false) assert.equal(isValidSessionId(''), false) assert.equal(isValidSessionId(null), false) assert.equal(isValidSessionId(Number.MAX_SAFE_INTEGER + 2), false) }) // ─── resolveAttachmentTarget ─── test('resolveAttachmentTarget 正常路径位于 home/.mao/attachments///', (t) => { const home = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'mao-attach-home-'))) t.after(() => fs.rmSync(home, { recursive: true, force: true })) const target = resolveAttachmentTarget(home, 'mao.etarch.cn', 7, 'x.txt') assert.equal( target, path.join(home, '.mao', 'attachments', 'mao.etarch.cn', '7', 'x.txt') ) }) test('resolveAttachmentTarget 拒绝 hostKey / sessionId 注入', (t) => { const home = fs.mkdtempSync(path.join(os.tmpdir(), 'mao-attach-home-')) t.after(() => fs.rmSync(home, { recursive: true, force: true })) assert.throws(() => resolveAttachmentTarget(home, '', 1, 'x.txt'), /服务器标识/) assert.throws(() => resolveAttachmentTarget(home, 'a/b', 1, 'x.txt'), /服务器标识/) assert.throws(() => resolveAttachmentTarget(home, '..', 1, 'x.txt'), /服务器标识/) assert.throws(() => resolveAttachmentTarget(home, 'host', '1/../../x', 'x.txt'), /会话无效/) assert.throws(() => resolveAttachmentTarget(home, 'host', 0, 'x.txt'), /会话无效/) }) test('resolveAttachmentTarget 嵌套逃逸文件名被 sanitize 收敛', (t) => { const home = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'mao-attach-home-'))) t.after(() => fs.rmSync(home, { recursive: true, force: true })) const target = resolveAttachmentTarget(home, 'host', 3, '../../etc/passwd') const rel = path.relative(home, target) assert.equal(rel, path.join('.mao', 'attachments', 'host', '3', 'passwd')) }) test('resolveAttachmentTarget home 为符号链接时先 resolve 再断言', (t) => { const real = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'mao-attach-real-'))) const link = `${real}-link` fs.symlinkSync(real, link) t.after(() => { fs.rmSync(real, { recursive: true, force: true }) fs.rmSync(link, { force: true }) }) const target = resolveAttachmentTarget(link, 'host', 5, 'x.txt') assert.ok(target.startsWith(real + path.sep), target) assert.ok(!target.startsWith(link + path.sep), target) }) test('resolveAttachmentTarget ~/.mao 存在且非目录时报可读错误', (t) => { const home = fs.mkdtempSync(path.join(os.tmpdir(), 'mao-attach-home-')) t.after(() => fs.rmSync(home, { recursive: true, force: true })) fs.writeFileSync(path.join(home, '.mao'), 'not a dir') assert.throws(() => resolveAttachmentTarget(home, 'host', 1, 'x.txt'), /不是目录/) }) // ─── uniqueName ─── test('uniqueName 无冲突原名、连续冲突递增加后缀', (t) => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mao-attach-uniq-')) t.after(() => fs.rmSync(dir, { recursive: true, force: true })) assert.equal(uniqueName(dir, 'x.txt'), 'x.txt') fs.writeFileSync(path.join(dir, 'x.txt'), '') assert.equal(uniqueName(dir, 'x.txt'), 'x-1.txt') fs.writeFileSync(path.join(dir, 'x-1.txt'), '') // x-1.txt 已存在:跳过取 x-2.txt assert.equal(uniqueName(dir, 'x.txt'), 'x-2.txt') fs.writeFileSync(path.join(dir, 'x-2.txt'), '') assert.equal(uniqueName(dir, 'x.txt'), 'x-3.txt') }) test('uniqueName 无扩展名与多点扩展名', (t) => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mao-attach-uniq-')) t.after(() => fs.rmSync(dir, { recursive: true, force: true })) fs.writeFileSync(path.join(dir, 'noext'), '') assert.equal(uniqueName(dir, 'noext'), 'noext-1') fs.writeFileSync(path.join(dir, 'a.tar.gz'), '') assert.equal(uniqueName(dir, 'a.tar.gz'), 'a.tar-1.gz') }) // ─── copyLocalAttachment(IPC handler 的纯逻辑内核) ─── function tempHome(t) { const home = fs.mkdtempSync(path.join(os.tmpdir(), 'mao-attach-copy-')) t.after(() => fs.rmSync(home, { recursive: true, force: true })) // macOS /var → /private/var:resolveAttachmentTarget 先 realpath home,期望值必须同口径 return fs.realpathSync(home) } test('copyLocalAttachment 成功:落盘 /.mao/attachments/// 且内容一致', (t) => { const home = tempHome(t) const src = path.join(home, 'src.txt') fs.writeFileSync(src, 'hello attachment') const result = copyLocalAttachment({ sourcePath: src, sessionId: 12, maxSizeMb: 10, homeDir: home, hostKey: 'mao.etarch.cn', }) assert.equal(result.ok, true, JSON.stringify(result)) const expectedDir = path.join(home, '.mao', 'attachments', 'mao.etarch.cn', '12') assert.equal(result.absolutePath, path.join(expectedDir, 'src.txt')) assert.equal(fs.readFileSync(result.absolutePath, 'utf8'), 'hello attachment') }) test('copyLocalAttachment 重名三次追加 -1/-2/-3', (t) => { const home = tempHome(t) const src = path.join(home, 'dup.bin') fs.writeFileSync(src, 'x') const results = [1, 2, 3].map((i) => copyLocalAttachment({ sourcePath: src, sessionId: 3, maxSizeMb: 10, homeDir: home, hostKey: 'host', }) ) assert.deepEqual(results.map((r) => path.basename(r.absolutePath)), ['dup.bin', 'dup-1.bin', 'dup-2.bin']) }) test('copyLocalAttachment 超限 / 空文件 / 目录 / 符号链接 / 不存在 五个分支', (t) => { const home = tempHome(t) const base = { sessionId: 9, maxSizeMb: 1, homeDir: home, hostKey: 'host' } // 超限 const big = path.join(home, 'big.bin') fs.writeFileSync(big, Buffer.alloc(1024 * 1024 + 1)) const tooBig = copyLocalAttachment({ ...base, sourcePath: big }) assert.equal(tooBig.ok, false) assert.match(tooBig.error, /超过/) // 空文件 const empty = path.join(home, 'empty.txt') fs.writeFileSync(empty, '') const emptyResult = copyLocalAttachment({ ...base, sourcePath: empty }) assert.equal(emptyResult.ok, false) assert.match(emptyResult.error, /为空/) // 目录 const dir = path.join(home, 'folder') fs.mkdirSync(dir) const dirResult = copyLocalAttachment({ ...base, sourcePath: dir }) assert.equal(dirResult.ok, false) assert.match(dirResult.error, /文件夹/) // 符号链接(lstat 拒绝,不跟随) const real = path.join(home, 'real.txt') fs.writeFileSync(real, 'real content') const link = path.join(home, 'link.txt') fs.symlinkSync(real, link) const linkResult = copyLocalAttachment({ ...base, sourcePath: link }) assert.equal(linkResult.ok, false) assert.match(linkResult.error, /符号链接/) // 不存在 const missing = copyLocalAttachment({ ...base, sourcePath: path.join(home, 'nope.txt') }) assert.equal(missing.ok, false) assert.match(missing.error, /不存在/) }) test('copyLocalAttachment 会话非法 / 路径为空直接拒绝', (t) => { const home = tempHome(t) const src = path.join(home, 'x.txt') fs.writeFileSync(src, 'x') const badSession = copyLocalAttachment({ sourcePath: src, sessionId: '1/../../x', homeDir: home, hostKey: 'host' }) assert.equal(badSession.ok, false) assert.match(badSession.error, /会话无效/) const noPath = copyLocalAttachment({ sourcePath: '', sessionId: 1, homeDir: home, hostKey: 'host' }) assert.equal(noPath.ok, false) }) test('copyLocalAttachment 恶意源文件名被 sanitize 后仍落在会话目录内', (t) => { const home = tempHome(t) const src = path.join(home, 'x.txt') fs.writeFileSync(src, 'payload') const result = copyLocalAttachment({ sourcePath: src, sessionId: 4, maxSizeMb: 10, homeDir: home, hostKey: 'host', }) assert.equal(result.ok, true) const rel = path.relative(home, result.absolutePath) assert.equal(rel, path.join('.mao', 'attachments', 'host', '4', 'x.txt')) }) test('copyLocalAttachment maxSizeMb 非法时回落默认上限', (t) => { const home = tempHome(t) const src = path.join(home, 'x.txt') fs.writeFileSync(src, 'x') const result = copyLocalAttachment({ sourcePath: src, sessionId: 4, maxSizeMb: undefined, homeDir: home, hostKey: 'host', }) assert.equal(result.ok, true) }) // ─── copyLocalAttachmentAsync(IPC 默认路径:异步拷贝 + 主进程大小封顶) ─── test('copyLocalAttachmentAsync 成功:内容一致且落在会话目录', async (t) => { const home = tempHome(t) const src = path.join(home, 'src.txt') fs.writeFileSync(src, 'async attachment') const result = await copyLocalAttachmentAsync({ sourcePath: src, sessionId: 12, maxSizeMb: 10, homeDir: home, hostKey: 'mao.etarch.cn', }) assert.equal(result.ok, true, JSON.stringify(result)) assert.equal(result.absolutePath, path.join(home, '.mao', 'attachments', 'mao.etarch.cn', '12', 'src.txt')) assert.equal(fs.readFileSync(result.absolutePath, 'utf8'), 'async attachment') }) test('copyLocalAttachmentAsync 主进程硬封顶:渲染层传入 1024 也不放行超限文件', async (t) => { const home = tempHome(t) // 稀疏文件:lstat.size 超限但不占真实磁盘 const big = path.join(home, 'huge.bin') const fd = fs.openSync(big, 'w') fs.ftruncateSync(fd, (MAIN_PROCESS_MAX_SIZE_MB + 100) * 1024 * 1024) fs.closeSync(fd) const result = await copyLocalAttachmentAsync({ sourcePath: big, sessionId: 5, maxSizeMb: 1024, homeDir: home, hostKey: 'host', }) assert.equal(result.ok, false) assert.match(result.error, new RegExp(`${MAIN_PROCESS_MAX_SIZE_MB}MB`)) // 校验失败不创建附件目录 assert.equal(fs.existsSync(path.join(home, '.mao', 'attachments')), false) }) test('copyLocalAttachmentAsync 与同步版同口径:符号链接 / 空文件 / 会话非法均拒', async (t) => { const home = tempHome(t) const base = { sessionId: 9, maxSizeMb: 10, homeDir: home, hostKey: 'host' } const real = path.join(home, 'real.txt') fs.writeFileSync(real, 'real') const link = path.join(home, 'link.txt') fs.symlinkSync(real, link) const linkResult = await copyLocalAttachmentAsync({ ...base, sourcePath: link }) assert.equal(linkResult.ok, false) assert.match(linkResult.error, /符号链接/) const empty = path.join(home, 'empty.txt') fs.writeFileSync(empty, '') const emptyResult = await copyLocalAttachmentAsync({ ...base, sourcePath: empty }) assert.equal(emptyResult.ok, false) assert.match(emptyResult.error, /为空/) const badSession = await copyLocalAttachmentAsync({ sourcePath: real, sessionId: '1/../../x', homeDir: home, hostKey: 'host' }) assert.equal(badSession.ok, false) assert.match(badSession.error, /会话无效/) }) test('copyLocalAttachmentAsync 拷贝过程失败返回 ok:false 不抛出', async (t) => { const home = tempHome(t) const src = path.join(home, 'x.txt') fs.writeFileSync(src, 'x') const fsImpl = { ...fs, promises: { copyFile: async () => { throw new Error('disk full') }, }, } const result = await copyLocalAttachmentAsync({ sourcePath: src, sessionId: 1, maxSizeMb: 10, homeDir: home, hostKey: 'host', fsImpl, }) assert.equal(result.ok, false) assert.match(result.error, /disk full/) }) // ─── writeLocalTextFileAsync(dump_user_messages 的 LOCAL 落点) ─── test('writeLocalTextFileAsync 成功:落在会话附件目录并返回本机绝对路径与字节数', async (t) => { const home = tempHome(t) const content = '14084181,14084183,14084185' const result = await writeLocalTextFileAsync({ fileName: 'user-message-1042.txt', content, sessionId: 9, homeDir: home, hostKey: 'mao.etarch.cn', }) assert.equal(result.ok, true, JSON.stringify(result)) const expected = path.join(home, '.mao', 'attachments', 'mao.etarch.cn', '9', 'user-message-1042.txt') assert.equal(result.absolutePath, expected) assert.equal(result.bytes, Buffer.byteLength(content, 'utf8')) assert.equal(fs.readFileSync(expected, 'utf8'), content) }) test('writeLocalTextFileAsync dedupe=false(dump 幂等):重复写入覆盖同一文件,不产生 -1 副本', async (t) => { const home = tempHome(t) const base = { fileName: 'user-message-7.txt', sessionId: 3, homeDir: home, hostKey: 'host', dedupe: false } const first = await writeLocalTextFileAsync({ ...base, content: 'v1' }) const second = await writeLocalTextFileAsync({ ...base, content: 'v2' }) assert.equal(first.ok, true, JSON.stringify(first)) assert.equal(second.ok, true, JSON.stringify(second)) assert.equal(second.absolutePath, first.absolutePath) const dir = path.join(home, '.mao', 'attachments', 'host', '3') assert.deepEqual(fs.readdirSync(dir), ['user-message-7.txt']) assert.equal(fs.readFileSync(first.absolutePath, 'utf8'), 'v2') }) test('writeLocalTextFileAsync 默认同名避重(粘贴附件语义):追加 -1 副本,不覆盖已有引用', async (t) => { const home = tempHome(t) const base = { fileName: 'pasted-text-1.txt', sessionId: 3, homeDir: home, hostKey: 'host' } const first = await writeLocalTextFileAsync({ ...base, content: 'first batch' }) const second = await writeLocalTextFileAsync({ ...base, content: 'second batch' }) assert.equal(first.ok, true, JSON.stringify(first)) assert.equal(second.ok, true, JSON.stringify(second)) assert.notEqual(second.absolutePath, first.absolutePath) const dir = path.join(home, '.mao', 'attachments', 'host', '3') assert.deepEqual(fs.readdirSync(dir).sort(), ['pasted-text-1-1.txt', 'pasted-text-1.txt']) assert.equal(fs.readFileSync(first.absolutePath, 'utf8'), 'first batch') assert.equal(fs.readFileSync(second.absolutePath, 'utf8'), 'second batch') }) test('writeLocalTextFileAsync 会话非法 / hostKey 非法 / 文件名缺失 / 内容非字符串均拒且不建目录', async (t) => { const home = tempHome(t) const base = { fileName: 'a.txt', content: 'x', sessionId: 1, homeDir: home, hostKey: 'host' } const badSession = await writeLocalTextFileAsync({ ...base, sessionId: '1/../../x' }) assert.equal(badSession.ok, false) assert.match(badSession.error, /会话无效/) const badHost = await writeLocalTextFileAsync({ ...base, hostKey: '../escape' }) assert.equal(badHost.ok, false) assert.match(badHost.error, /服务器标识/) const noName = await writeLocalTextFileAsync({ ...base, fileName: undefined }) assert.equal(noName.ok, false) assert.match(noName.error, /文件名无效/) const badContent = await writeLocalTextFileAsync({ ...base, content: null }) assert.equal(badContent.ok, false) assert.match(badContent.error, /文本内容无效/) assert.equal(fs.existsSync(path.join(home, '.mao')), false) }) test('writeLocalTextFileAsync 穿越文件名被 sanitize 后仍落在会话目录内', async (t) => { const home = tempHome(t) const result = await writeLocalTextFileAsync({ fileName: '../../user-message-9.txt', content: 'x', sessionId: 4, homeDir: home, hostKey: 'host', }) assert.equal(result.ok, true, JSON.stringify(result)) const rel = path.relative(home, result.absolutePath) assert.equal(rel, path.join('.mao', 'attachments', 'host', '4', 'user-message-9.txt')) }) test('writeLocalTextFileAsync 超过大小上限时拒绝', async (t) => { const home = tempHome(t) const result = await writeLocalTextFileAsync({ fileName: 'big.txt', content: 'x'.repeat(2000), sessionId: 5, maxSizeMb: 0.001, homeDir: home, hostKey: 'host', }) assert.equal(result.ok, false) assert.match(result.error, /超过/) }) test('writeLocalTextFileAsync 写盘失败返回 ok:false 不抛出', async (t) => { const home = tempHome(t) const fsImpl = { ...fs, promises: { writeFile: async () => { throw new Error('read-only file system') }, }, } const result = await writeLocalTextFileAsync({ fileName: 'a.txt', content: 'x', sessionId: 1, homeDir: home, hostKey: 'host', fsImpl, }) assert.equal(result.ok, false) assert.match(result.error, /read-only file system/) })