diff --git a/packages/channels/qqbot/src/QQChannel.ts b/packages/channels/qqbot/src/QQChannel.ts index 4aaed4a1ec9..b3c95d63e4f 100644 --- a/packages/channels/qqbot/src/QQChannel.ts +++ b/packages/channels/qqbot/src/QQChannel.ts @@ -26,7 +26,14 @@ import type { ChannelAgentBridge, } from '@qwen-code/channel-base'; import WebSocket from 'ws'; -import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'; +import { + readFileSync, + writeFileSync, + existsSync, + mkdirSync, + renameSync, + unlinkSync, +} from 'node:fs'; import { join } from 'node:path'; import { OpCode, Intent } from './types.js'; import type { @@ -128,6 +135,8 @@ export class QQChannel extends ChannelBase { private lastHeartbeatAck: number = 0; /** Debounce timer for saveQQState to avoid blocking event loop. */ private saveTimer: ReturnType | null = null; + /** beforeExit hook to flush state when the event loop drains naturally. Does NOT fire for SIGKILL, OOM kills, or uncaughtException. */ + private beforeExitHook: (() => void) | null = null; /** Timer for reconnectWithRetry fallback (unref'd so it doesn't block exit). */ private reconnectTimer: ReturnType | null = null; /** Guard against parallel reconnectWithRetry chains from stale close events. */ @@ -199,16 +208,30 @@ export class QQChannel extends ChannelBase { try { await this.fetchToken(); await this.connectGateway(); + // Register beforeExit hook so the unref'd debounce timer's unflushed + // state is persisted when the event loop drains naturally. Does NOT + // fire for SIGKILL, OOM kills, or uncaughtException. + if (this.beforeExitHook) { + process.off('beforeExit', this.beforeExitHook); + } + this.beforeExitHook = () => this.flushQQState(); + process.on('beforeExit', this.beforeExitHook); return; } catch (e: unknown) { if (attempt < 2) { const msg = e instanceof Error ? e.message : String(e); process.stderr.write( - `[QQ:${this.name}] Connect attempt ${attempt + 1} failed: ${msg}, retrying...\n`, + `[QQ:${this.name}] Connect attempt ${attempt + 1} failed: ${sanitizeLogText(msg, 200)}, retrying...\n`, ); await this.sleep(2000); } else { - throw e; + // Final attempt: wrap the connection error with sanitized text. + // The sanitizeLogText path is exercised by the existing connect gateway + // retry tests in send.test.ts (gateway reconnect timer block). + throw new Error( + sanitizeLogText(e instanceof Error ? e.message : String(e), 200), + { cause: e }, + ); } } } @@ -253,7 +276,7 @@ export class QQChannel extends ChannelBase { if (!resp.ok && useMarkdown) { const errBody = await resp.text().catch(() => ''); process.stderr.write( - `[QQ:${this.name}] Markdown rejected (HTTP ${resp.status}: ${errBody.slice(0, 100)}), retrying as plain text\n`, + `[QQ:${this.name}] Markdown rejected (HTTP ${resp.status}: ${sanitizeLogText(errBody, 200)}), retrying as plain text\n`, ); const plainBody: Record = { content: chunk, @@ -275,14 +298,16 @@ export class QQChannel extends ChannelBase { // Drain response body to avoid socket leak const errBody = await resp.text().catch(() => ''); process.stderr.write( - `[QQ:${this.name}] Send HTTP ${resp.status} (msg_seq=${body['msg_seq'] ?? '-'}): ${errBody.slice(0, 200)}\n`, + `[QQ:${this.name}] Send HTTP ${resp.status} (msg_seq=${body['msg_seq'] ?? '-'}): ${sanitizeLogText(errBody, 200)}\n`, ); break; // stop sending on failure to avoid msg_seq gaps } // Only persist seq on success if (msgId) this.msgSeqMap.set(msgId, nextSeq); } catch (e) { - process.stderr.write(`[QQ:${this.name}] Send error: ${e}\n`); + process.stderr.write( + `[QQ:${this.name}] Send error: ${sanitizeLogText(e instanceof Error ? e.message : String(e), 200)}\n`, + ); break; } } @@ -326,6 +351,10 @@ export class QQChannel extends ChannelBase { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } + if (this.beforeExitHook) { + process.off('beforeExit', this.beforeExitHook); + this.beforeExitHook = null; + } this.flushQQState(); this.backupGlobalSessions(); if (this.ws) { @@ -362,11 +391,16 @@ export class QQChannel extends ChannelBase { /** Debounced state persistence to avoid blocking event loop. */ private saveQQState(): void { + // NOTE: guarded here; flushQQState() is intentionally NOT — disconnect() + // sets disposed=true *before* calling it, so it must still write final state. + if (this.disposed) return; if (this.saveTimer) clearTimeout(this.saveTimer); + const tmpPath = this.qqStatePath + '.tmp'; this.saveTimer = setTimeout(() => { + if (this.disposed) return; try { writeFileSync( - this.qqStatePath, + tmpPath, JSON.stringify({ chatTypeMap: Array.from(this.chatTypeMap.entries()), replyMsgId: Array.from(this.replyMsgId.entries()), @@ -374,10 +408,19 @@ export class QQChannel extends ChannelBase { }), { mode: 0o600 }, ); - } catch { - /* best-effort */ + renameSync(tmpPath, this.qqStatePath); + } catch (e) { + try { + unlinkSync(tmpPath); + } catch { + /* best-effort */ + } + process.stderr.write( + `[QQ:${this.name}] saveQQState write failed: ${sanitizeLogText(e instanceof Error ? e.message : String(e), 200)}\n`, + ); } }, 500); + this.saveTimer.unref(); } /** Flush pending state writes immediately (called on disconnect). */ @@ -386,37 +429,104 @@ export class QQChannel extends ChannelBase { clearTimeout(this.saveTimer); this.saveTimer = null; } + const tmpPath = this.qqStatePath + '.tmp'; try { writeFileSync( - this.qqStatePath, + tmpPath, JSON.stringify({ chatTypeMap: Array.from(this.chatTypeMap.entries()), replyMsgId: Array.from(this.replyMsgId.entries()), msgSeqMap: Array.from(this.msgSeqMap.entries()), }), + { mode: 0o600 }, + ); + renameSync(tmpPath, this.qqStatePath); + } catch (e) { + try { + unlinkSync(tmpPath); + } catch { + /* best-effort */ + } + process.stderr.write( + `[QQ:${this.name}] flushQQState write failed: ${sanitizeLogText(e instanceof Error ? e.message : String(e), 200)}\n`, ); - } catch { - /* best-effort */ } } /** * Restore QQ routing state from disk. - * Trusts persisted JSON — if the file is corrupt, new Map() may create - * entries with undefined values, causing get()===undefined to fall through - * to default routing (C2C). This is acceptable for a rare edge case. + * Validates and filters every entry on restore — corrupt or unexpected + * entries (e.g. unknown chat types, oversized replyMsgIds, negative seqs) + * are silently dropped so they don't propagate into runtime routing. */ private restoreQQState(): boolean { try { if (!existsSync(this.qqStatePath)) return false; const raw = JSON.parse(readFileSync(this.qqStatePath, 'utf-8')); - if (raw.chatTypeMap) this.chatTypeMap = new Map(raw.chatTypeMap); - if (raw.replyMsgId) this.replyMsgId = new Map(raw.replyMsgId); - if (raw.msgSeqMap) this.msgSeqMap = new Map(raw.msgSeqMap); + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + process.stderr.write( + `[QQ:${this.name}] Invalid QQ state file (not an object), ignoring\n`, + ); + return false; + } + if (raw.chatTypeMap && Array.isArray(raw.chatTypeMap)) { + const rawCT = raw.chatTypeMap as Array<[string, unknown]>; + // Validate: only accept 'c2c' | 'group' values + this.chatTypeMap = new Map( + rawCT.filter( + ([k, v]) => + typeof k === 'string' && + k.length <= 256 && + (v === 'c2c' || v === 'group'), + ), + ) as Map; + const dropped = rawCT.length - this.chatTypeMap.size; + if (dropped > 0) + process.stderr.write( + `[QQ:${this.name}] Dropped ${dropped} invalid chatTypeMap entries during restore\n`, + ); + } + if (raw.replyMsgId && Array.isArray(raw.replyMsgId)) { + const rawRM = raw.replyMsgId as Array<[string, unknown]>; + // Validate: entries must be strings ≤ 128 chars + this.replyMsgId = new Map( + rawRM.filter( + ([k, v]) => + typeof k === 'string' && + k.length <= 256 && + typeof v === 'string' && + v.length <= 128, + ), + ) as Map; + const dropped = rawRM.length - this.replyMsgId.size; + if (dropped > 0) + process.stderr.write( + `[QQ:${this.name}] Dropped ${dropped} invalid replyMsgId entries during restore\n`, + ); + } + if (raw.msgSeqMap && Array.isArray(raw.msgSeqMap)) { + const rawMS = raw.msgSeqMap as Array<[string, unknown]>; + // Validate: entries must be non-negative safe integers + this.msgSeqMap = new Map( + rawMS.filter( + ([k, v]) => + typeof k === 'string' && + k.length <= 256 && + typeof v === 'number' && + Number.isSafeInteger(v) && + v >= 0, + ), + ) as Map; + const dropped = rawMS.length - this.msgSeqMap.size; + if (dropped > 0) + process.stderr.write( + `[QQ:${this.name}] Dropped ${dropped} invalid msgSeqMap entries during restore\n`, + ); + } return true; } catch (e) { process.stderr.write( - `[QQ:${this.name}] Failed to restore QQ state: ${e instanceof Error ? e.message : String(e)}\n`, + `[QQ:${this.name}] Failed to restore QQ state: ${sanitizeLogText(e instanceof Error ? e.message : String(e), 200)}\n`, ); return false; } @@ -549,7 +659,7 @@ export class QQChannel extends ChannelBase { this.fetchToken().catch((e) => { if (this.disposed) return; process.stderr.write( - `[QQ:${this.name}] Token refresh failed: ${e}, retrying in 60s\n`, + `[QQ:${this.name}] Token refresh failed: ${sanitizeLogText(e instanceof Error ? e.message : String(e), 200)}, retrying in 60s\n`, ); this.scheduleTokenRefreshRetry(); }); @@ -564,7 +674,7 @@ export class QQChannel extends ChannelBase { this.fetchToken().catch((e) => { if (this.disposed) return; process.stderr.write( - `[QQ:${this.name}] Token refresh failed: ${e}, retrying in 60s\n`, + `[QQ:${this.name}] Token refresh failed: ${sanitizeLogText(e instanceof Error ? e.message : String(e), 200)}, retrying in 60s\n`, ); this.scheduleTokenRefreshRetry(); }); @@ -611,7 +721,7 @@ export class QQChannel extends ChannelBase { this.handleGatewayMessage(msg, resolve); } catch (e) { process.stderr.write( - `[QQ:${this.name}] Malformed gateway message: ${e instanceof Error ? e.message : String(e)}\n`, + `[QQ:${this.name}] Malformed gateway message: ${sanitizeLogText(e instanceof Error ? e.message : String(e), 200)}\n`, ); } }); @@ -680,7 +790,9 @@ export class QQChannel extends ChannelBase { }); this.ws.on('error', (e: Error) => { - process.stderr.write(`[QQ:${this.name}] WebSocket error: ${e.message}\n`); + process.stderr.write( + `[QQ:${this.name}] WebSocket error: ${sanitizeLogText(e.message, 200)}\n`, + ); if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { reject(e); } @@ -853,7 +965,7 @@ export class QQChannel extends ChannelBase { const msg = e instanceof Error ? e.message : String(e); const backoff = Math.min(1000 * 2 ** (attempt + 1), 30000); process.stderr.write( - `[QQ:${this.name}] RC: ${msg} (retry in ${backoff}ms, attempt ${attempt + 1}/${maxGwRetries})\n`, + `[QQ:${this.name}] RC: ${sanitizeLogText(msg, 200)} (retry in ${backoff}ms, attempt ${attempt + 1}/${maxGwRetries})\n`, ); if (attempt < maxGwRetries - 1) await this.sleep(backoff); } @@ -944,7 +1056,9 @@ export class QQChannel extends ChannelBase { isMentioned: true, isReplyToBot: false, }).catch((e) => - process.stderr.write(`[QQ:${this.name}] C2C handler error: ${e}\n`), + process.stderr.write( + `[QQ:${this.name}] C2C handler error: ${sanitizeLogText(e instanceof Error ? e.message : String(e), 200)}\n`, + ), ); } @@ -1013,7 +1127,9 @@ export class QQChannel extends ChannelBase { isReplyToBot: true, ...(isSlash ? {} : { alreadyPrefixed: true as const }), }).catch((e) => - process.stderr.write(`[QQ:${this.name}] Group handler error: ${e}\n`), + process.stderr.write( + `[QQ:${this.name}] Group handler error: ${sanitizeLogText(e instanceof Error ? e.message : String(e), 200)}\n`, + ), ); } } diff --git a/packages/channels/qqbot/src/api.test.ts b/packages/channels/qqbot/src/api.test.ts index 07031711af8..01cc9c9428d 100644 --- a/packages/channels/qqbot/src/api.test.ts +++ b/packages/channels/qqbot/src/api.test.ts @@ -17,8 +17,13 @@ vi.stubGlobal( }, ); -const { fetchAccessToken, fetchGatewayUrl, getApiBase, sendQQMessage } = - await import('./api.js'); +const { + fetchAccessToken, + fetchGatewayUrl, + getApiBase, + sendQQMessage, + validateGatewayUrl, +} = await import('./api.js'); function mockResponse(ok: boolean, status: number, body: unknown): Response { return { @@ -26,7 +31,12 @@ function mockResponse(ok: boolean, status: number, body: unknown): Response { status, text: async () => (typeof body === 'string' ? body : JSON.stringify(body)), json: async () => (typeof body === 'string' ? JSON.parse(body) : body), - } as Response; + // body?.cancel() is a no-op in tests — fetchAccessToken now calls + // resp.body?.cancel() in the error path to drain Undici connections. + body: { + cancel: async () => undefined, + }, + } as unknown as Response; } describe('getApiBase', () => { @@ -107,11 +117,13 @@ describe('fetchAccessToken', () => { expect(result).toEqual({ accessToken: 'tok-no-exp', expiresIn: 7200 }); }); - it('throws on HTTP error', async () => { - mockFetch.mockResolvedValue(mockResponse(false, 401, 'unauthorized')); + it('throws on HTTP error with exact status-only message (no body leak)', async () => { + mockFetch.mockResolvedValue(mockResponse(false, 401, 'unauthorized-body')); + // Exact regex: the message must NOT contain the response body — a + // regression that reintroduces `: ${body}` would fail this assertion. await expect(fetchAccessToken('bad', 'bad')).rejects.toThrow( - 'QQ Bot token request failed (HTTP 401)', + /^QQ Bot token request failed \(HTTP 401\)$/, ); }); @@ -190,3 +202,116 @@ describe('fetchGatewayUrl', () => { ); }); }); + +describe('validateGatewayUrl', () => { + it('rejects https URLs', () => { + expect(() => validateGatewayUrl('https://gateway.qq.com/ws')).toThrow( + 'wss://', + ); + }); + + it('rejects http URLs', () => { + expect(() => validateGatewayUrl('http://gateway.qq.com/ws')).toThrow( + 'wss://', + ); + }); + + it('rejects ws URLs', () => { + expect(() => validateGatewayUrl('ws://gateway.qq.com/ws')).toThrow( + 'wss://', + ); + }); + + it('accepts valid wss URL on *.qq.com', () => { + const url = 'wss://api.sgroup.qq.com/ws'; + expect(validateGatewayUrl(url)).toBe(url); + }); + + it('accepts sandbox wss URL on *.qq.com', () => { + const url = 'wss://sandbox.api.sgroup.qq.com/ws'; + expect(validateGatewayUrl(url)).toBe(url); + }); + + it('rejects *.tencentcs.com (attacker-controlled Tencent Cloud API Gateway)', () => { + const url = 'wss://service-apigw.tencentcs.com/ws'; + expect(() => validateGatewayUrl(url)).toThrow('unexpected hostname'); + }); + + it('rejects *.tencent.com (broad suffix)', () => { + const url = 'wss://malicious.tencent.com/ws'; + expect(() => validateGatewayUrl(url)).toThrow('unexpected hostname'); + }); + + it('rejects unknown hostname', () => { + const url = 'wss://evil.example.com/ws'; + expect(() => validateGatewayUrl(url)).toThrow('unexpected hostname'); + }); + + it('rejects invalid URLs', () => { + expect(() => validateGatewayUrl('not a valid url')).toThrow( + 'not a valid URL', + ); + }); + + it('strips userinfo from valid wss URL with embedded credentials', () => { + const url = 'wss://user:password@gateway.qq.com/ws'; + const result = validateGatewayUrl(url); + expect(result).toBe('wss://gateway.qq.com/ws'); + expect(result).not.toContain('user'); + expect(result).not.toContain('password'); + }); +}); + +describe('fetchGatewayUrl + validateGatewayUrl integration', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('rejects when API returns a non-wss URL (http://)', async () => { + mockFetch.mockResolvedValue( + mockResponse(true, 200, { url: 'http://gateway.qq.com/ws' }), + ); + + await expect(fetchGatewayUrl('tok', false)).rejects.toThrow('wss://'); + }); + + it('rejects when API returns a non-wss URL (ws://)', async () => { + mockFetch.mockResolvedValue( + mockResponse(true, 200, { url: 'ws://gateway.qq.com/ws' }), + ); + + await expect(fetchGatewayUrl('tok', false)).rejects.toThrow('wss://'); + }); + + it('rejects when API returns a *.tencentcs.com wss:// URL', async () => { + mockFetch.mockResolvedValue( + mockResponse(true, 200, { + url: 'wss://bot-123.apigw.tencentcs.com/ws', + }), + ); + + await expect(fetchGatewayUrl('tok', false)).rejects.toThrow( + 'unexpected hostname', + ); + }); + + it('rejects when API returns an invalid URL', async () => { + mockFetch.mockResolvedValue( + mockResponse(true, 200, { url: 'not a valid url' }), + ); + + await expect(fetchGatewayUrl('tok', false)).rejects.toThrow( + 'not a valid URL', + ); + }); + + it('accepts when API returns a valid wss:// URL', async () => { + mockFetch.mockResolvedValue( + mockResponse(true, 200, { url: 'wss://api.sgroup.qq.com/ws' }), + ); + + await expect(fetchGatewayUrl('tok', false)).resolves.toBe( + 'wss://api.sgroup.qq.com/ws', + ); + }); +}); diff --git a/packages/channels/qqbot/src/api.ts b/packages/channels/qqbot/src/api.ts index d80061576f3..110052b888a 100644 --- a/packages/channels/qqbot/src/api.ts +++ b/packages/channels/qqbot/src/api.ts @@ -35,10 +35,9 @@ export async function fetchAccessToken( }); if (!resp.ok) { - const body = await resp.text().catch(() => ''); - throw new Error( - `QQ Bot token request failed (HTTP ${resp.status}): ${body}`, - ); + await resp.body?.cancel().catch(() => {}); + process.stderr.write(`[QQ] Token request failed (HTTP ${resp.status})\n`); + throw new Error(`QQ Bot token request failed (HTTP ${resp.status})`); } const data = (await resp.json()) as { @@ -54,6 +53,43 @@ export async function fetchAccessToken( }; } +/** + * Validate the WebSocket Gateway URL to enforce TLS and known hostname. + * - Enforces wss:// protocol (hard boundary — throws on non-wss). + * - Rejects hostnames outside `*.qq.com` (hard boundary). + * + * The QQ Bot Open Platform documents all endpoints under qq.com domains + * (api.sgroup.qq.com, sandbox.api.sgroup.qq.com, bots.qq.com). Broader + * suffixes like *.tencentcs.com would accept attacker-controlled Tencent + * Cloud API Gateway default domains, creating a token-exfiltration vector + * if /gateway is tampered with or misdirected. + */ +export function validateGatewayUrl(url: string): string { + try { + const parsed = new URL(url); + if (parsed.protocol !== 'wss:') { + throw new Error( + `QQ Bot gateway URL must use wss:// protocol, got: ${parsed.protocol}`, + ); + } + // Hard reject: only allow documented QQ gateway hostnames + if (!parsed.hostname.toLowerCase().endsWith('.qq.com')) { + throw new Error( + `QQ Bot gateway URL has unexpected hostname: ${parsed.hostname} (expected *.qq.com)`, + ); + } + const clean = new URL(url); + clean.username = ''; + clean.password = ''; + return clean.href; + } catch (e) { + if (e instanceof TypeError) { + throw new Error('QQ Bot gateway URL is not a valid URL'); + } + throw e; + } +} + /** * Resolve the WebSocket Gateway URL. * Throws on HTTP errors or missing URL in the response. @@ -70,6 +106,7 @@ export async function fetchGatewayUrl( }); if (!resp.ok) { + await resp.body?.cancel().catch(() => {}); throw new Error(`QQ Bot gateway request failed (HTTP ${resp.status})`); } @@ -77,7 +114,7 @@ export async function fetchGatewayUrl( if (!data['url']) { throw new Error('QQ Bot gateway response missing WebSocket URL'); } - return data['url']; + return validateGatewayUrl(data['url']); } /** Determine the API base URL from the sandbox flag. */ diff --git a/packages/channels/qqbot/src/send.test.ts b/packages/channels/qqbot/src/send.test.ts index 6532f7a24f2..7ec76a394de 100644 --- a/packages/channels/qqbot/src/send.test.ts +++ b/packages/channels/qqbot/src/send.test.ts @@ -52,6 +52,7 @@ vi.mock('node:fs', () => ({ mkdirSync: vi.fn(), readFileSync: vi.fn(), writeFileSync: vi.fn(), + renameSync: vi.fn(), existsSync: vi.fn(() => false), })); @@ -62,6 +63,7 @@ vi.mock('./api.js', () => ({ fetchGatewayUrl: mockFetchGatewayUrl, })); +import { renameSync, writeFileSync, readFileSync, existsSync } from 'node:fs'; vi.mock('ws', () => ({ default: MockWebSocket, })); @@ -886,3 +888,391 @@ describe('gateway reconnect timer', () => { } }); }); + +describe('connect() sanitized-error on final retry', () => { + function makeChannel(): QQChannelInstance { + return new QQChannel( + 'test-bot', + { + type: 'qq', + token: '', + senderPolicy: 'open' as const, + allowedUsers: [], + sessionScope: 'user' as const, + cwd: '/tmp', + groupPolicy: 'disabled' as const, + groups: {}, + appID: 'test-app-id', + appSecret: 'test-secret', + }, + {} as unknown as ChannelAgentBridge, + ); + } + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('sanitizes error message containing newlines and control chars in final retry throw', async () => { + vi.useFakeTimers(); + + // fetchToken succeeds each attempt + mockFetchAccessToken.mockResolvedValue({ + accessToken: 'tok', + expiresIn: 7200, + }); + // fetchGatewayUrl always fails with dangerous text — exercised 3× by + // the 3-attempt connect loop + mockFetchGatewayUrl.mockRejectedValue( + new Error('wss://evil\nhost\x00leaked\tsecret'), + ); + // Suppress noisy stderr writes from the retry log lines + const stderrSpy = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + // Suppress unhandledRejection from the { cause: e } chain + const onUnhandled = vi.fn(); + process.on('unhandledRejection', onUnhandled); + const ch = makeChannel(); + const connectPromise = ( + ch as unknown as { connect: () => Promise } + ).connect.call(ch); + + // Advance past the two retry sleeps in the connect loop + await vi.advanceTimersByTimeAsync(2000); + await vi.advanceTimersByTimeAsync(2000); + + await expect(connectPromise).rejects.toThrow(); + + try { + await connectPromise; + } catch (e) { + const msg = (e as Error).message; + // The message must have been sanitized: no raw newlines, no NUL, no tab + expect(msg).not.toContain('\n'); + expect(msg).not.toContain('\0'); + expect(msg).not.toContain('\t'); + // sanitizeLogText strips control characters (newlines, NUL, tabs) + // but preserves readable content — the message should still contain + // the readable parts of the error. + expect(msg).toContain('wss://'); + expect(msg).toContain('evil'); + expect(msg).toContain('secret'); + } + + stderrSpy.mockRestore(); + process.off('unhandledRejection', onUnhandled); + }); +}); + +describe('restoreQQState validation filters', () => { + function makeChannel(): QQChannelInstance { + return new QQChannel( + 'test-bot', + { + type: 'qq', + token: '', + senderPolicy: 'open' as const, + allowedUsers: [], + sessionScope: 'user' as const, + cwd: '/tmp', + groupPolicy: 'disabled' as const, + groups: {}, + appID: 'test-app-id', + appSecret: 'test-secret', + }, + {} as unknown as ChannelAgentBridge, + ); + } + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('filters chatTypeMap to only accept c2c and group values', () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ + chatTypeMap: [ + ['a', 'c2c'], + ['b', 'group'], + ['c', 'unknown'], + ['d', null], + ['e', ''], + ], + }), + ); + + const ch = makeChannel(); + (ch as unknown as { restoreQQState: () => boolean }).restoreQQState(); + + const chatTypeMap = (ch as unknown as { chatTypeMap: Map }) + .chatTypeMap; + expect(chatTypeMap.size).toBe(2); + expect(chatTypeMap.get('a')).toBe('c2c'); + expect(chatTypeMap.get('b')).toBe('group'); + expect(chatTypeMap.has('c')).toBe(false); + expect(chatTypeMap.has('d')).toBe(false); + expect(chatTypeMap.has('e')).toBe(false); + }); + + it('filters replyMsgId to only accept strings ≤ 128 chars', () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ + replyMsgId: [ + ['a', 'valid-id'], + ['b', 'x'.repeat(128)], + ['c', 'x'.repeat(129)], + ['d', 123], + ['e', null], + ['f', ''], + ], + }), + ); + + const ch = makeChannel(); + (ch as unknown as { restoreQQState: () => boolean }).restoreQQState(); + + const replyMsgId = (ch as unknown as { replyMsgId: Map }) + .replyMsgId; + expect(replyMsgId.size).toBe(3); + expect(replyMsgId.get('a')).toBe('valid-id'); + expect(replyMsgId.get('b')).toBe('x'.repeat(128)); + expect(replyMsgId.get('f')).toBe(''); + expect(replyMsgId.has('c')).toBe(false); + expect(replyMsgId.has('d')).toBe(false); + expect(replyMsgId.has('e')).toBe(false); + }); + + it('filters msgSeqMap to only accept non-negative numbers', () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ + msgSeqMap: [ + ['a', 0], + ['b', 42], + ['c', -1], + ['d', 'string'], + ['e', null], + ['f', 3.14], + ['g', Infinity], + ], + }), + ); + + const ch = makeChannel(); + (ch as unknown as { restoreQQState: () => boolean }).restoreQQState(); + + const msgSeqMap = (ch as unknown as { msgSeqMap: Map }) + .msgSeqMap; + expect(msgSeqMap.size).toBe(2); + expect(msgSeqMap.get('a')).toBe(0); + expect(msgSeqMap.get('b')).toBe(42); + expect(msgSeqMap.has('c')).toBe(false); + expect(msgSeqMap.has('d')).toBe(false); + expect(msgSeqMap.has('e')).toBe(false); + expect(msgSeqMap.has('f')).toBe(false); + expect(msgSeqMap.has('g')).toBe(false); + }); + + it('filters non-safe-integer msgSeqMap values (fractional, overflow, Infinity, -Infinity)', () => { + vi.mocked(existsSync).mockReturnValue(true); + // Number.MAX_SAFE_INTEGER + 1 = 9007199254740992 — loses precision + // 1e999 / -1e999 are parsed as Infinity / -Infinity by JSON.parse + // Use raw JSON string: JSON.stringify(Infinity) → "null", which + // bypasses Number.isSafeInteger (caught by typeof check instead). + vi.mocked(readFileSync).mockReturnValue( + '{"msgSeqMap":[["a",1.5],["b",9007199254740992],["c",1e999],["d",-1e999],["e",42],["f",0]]}', + ); + + const ch = makeChannel(); + (ch as unknown as { restoreQQState: () => boolean }).restoreQQState(); + + const msgSeqMap = (ch as unknown as { msgSeqMap: Map }) + .msgSeqMap; + expect(msgSeqMap.size).toBe(2); + expect(msgSeqMap.get('e')).toBe(42); + expect(msgSeqMap.get('f')).toBe(0); + // Edge cases must ALL be filtered by Number.isSafeInteger + expect(msgSeqMap.has('a')).toBe(false); + expect(msgSeqMap.has('b')).toBe(false); + expect(msgSeqMap.has('c')).toBe(false); + expect(msgSeqMap.has('d')).toBe(false); + }); + + it('returns false and does not throw on corrupt JSON', () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue('not json{{{'); + + const ch = makeChannel(); + const result = ( + ch as unknown as { restoreQQState: () => boolean } + ).restoreQQState(); + expect(result).toBe(false); + }); + + it('returns false when state file does not exist', () => { + vi.mocked(existsSync).mockReturnValue(false); + + const ch = makeChannel(); + const result = ( + ch as unknown as { restoreQQState: () => boolean } + ).restoreQQState(); + expect(result).toBe(false); + }); + + it('returns false on non-object JSON (number)', () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue('42'); + + const ch = makeChannel(); + const result = ( + ch as unknown as { restoreQQState: () => boolean } + ).restoreQQState(); + expect(result).toBe(false); + }); + + it('returns false on non-object JSON (string)', () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue('"state"'); + + const ch = makeChannel(); + const result = ( + ch as unknown as { restoreQQState: () => boolean } + ).restoreQQState(); + expect(result).toBe(false); + }); + + it('returns false on non-object JSON (array)', () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue('[1,2,3]'); + + const ch = makeChannel(); + const result = ( + ch as unknown as { restoreQQState: () => boolean } + ).restoreQQState(); + expect(result).toBe(false); + }); + + it('returns false on null JSON', () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue('null'); + + const ch = makeChannel(); + const result = ( + ch as unknown as { restoreQQState: () => boolean } + ).restoreQQState(); + expect(result).toBe(false); + }); +}); + +describe('atomic state persistence', () => { + function makeChannel(): QQChannelInstance { + return new QQChannel( + 'test-bot', + { + type: 'qq', + token: '', + senderPolicy: 'open' as const, + allowedUsers: [], + sessionScope: 'user' as const, + cwd: '/tmp', + groupPolicy: 'disabled' as const, + groups: {}, + appID: 'test-app-id', + appSecret: 'test-secret', + }, + {} as unknown as ChannelAgentBridge, + ); + } + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('flushQQState writes to tmp path then renames to final path', () => { + const ch = makeChannel(); + const chp = ch as unknown as { + flushQQState: () => void; + qqStatePath: string; + }; + + chp.flushQQState(); + + // First writes to tmp, then renames + const writeCalls = vi.mocked(writeFileSync).mock.calls; + const renameCalls = vi.mocked(renameSync).mock.calls; + + expect(writeCalls.length).toBeGreaterThanOrEqual(1); + expect(renameCalls.length).toBeGreaterThanOrEqual(1); + + // The write should target the .tmp path + const writeTarget = writeCalls[0][0] as string; + expect(writeTarget).toContain('.tmp'); + + // The rename should go from .tmp to the final path + expect(renameCalls[0][0]).toBe(writeTarget); + expect(renameCalls[0][1]).toBe(chp.qqStatePath); + }); + + it('flushQQState writes valid JSON with expected keys', () => { + const ch = makeChannel(); + const chp = ch as unknown as { flushQQState: () => void }; + + chp.flushQQState(); + + const writeCalls = vi.mocked(writeFileSync).mock.calls; + expect(writeCalls.length).toBeGreaterThanOrEqual(1); + + const written = JSON.parse(writeCalls[0][1] as string); + expect(written).toHaveProperty('chatTypeMap'); + expect(written).toHaveProperty('replyMsgId'); + expect(written).toHaveProperty('msgSeqMap'); + }); + + it('flushQQState sets file mode 0o600', () => { + const ch = makeChannel(); + (ch as unknown as { flushQQState: () => void }).flushQQState(); + + const writeCalls = vi.mocked(writeFileSync).mock.calls; + expect(writeCalls.length).toBeGreaterThanOrEqual(1); + expect(writeCalls[0][2]).toEqual({ mode: 0o600 }); + }); + + it('saveQQState sets debounced unref timer', () => { + const ch = makeChannel(); + const chp = ch as unknown as { + saveQQState: () => void; + saveTimer: ReturnType | null; + }; + + chp.saveQQState(); + + expect(chp.saveTimer).not.toBeNull(); + expect(chp.saveTimer?.hasRef()).toBe(false); + }); + + it('does not write state when disposed after timer is scheduled', () => { + vi.useFakeTimers(); + const ch = makeChannel(); + const chp = ch as unknown as { + saveQQState: () => void; + saveTimer: ReturnType | null; + }; + + // Schedule a save + chp.saveQQState(); + expect(chp.saveTimer).not.toBeNull(); + + // Mark disposed before the timer fires + (ch as unknown as { disposed: boolean }).disposed = true; + + // Advance time past debounce interval + vi.advanceTimersByTime(600); + + // The callback should have returned early due to disposed check + expect(writeFileSync).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); +});