SnowLuma v2.4.2 source snapshot
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run

This commit is contained in:
TanTan committed 2026-11-13 09:02:00 +08:00
commit fa9c73363d
1367 files changed
+355735

No files matched your search

@@ -0,0 +1,739 @@
import { describe, it, expect, vi } from 'vitest';
import { EventEmitter } from 'events';
import { HookManager } from '../src/hook-manager';
import { PipeWatcher } from '../src/pipe-watcher';
import type { ManualMapHandle } from '../src/injector';
import type { BridgeManagerSink } from '../src/hook-manager';
import type { QqHookClient } from '../src/qq-hook-client';
import type { AccountLoginState } from '../src/qq-account';
// Regression cover for the WebUI SSE wiring: HookManager must call its
// optional `onSessionsChanged` callback every time the observable set of
// HookProcessInfo (what `listProcesses()` would return) changes, so the
// /api/state/stream handler can push a fresh processes snapshot to the
// browser without REST polling.
const DUMMY_HANDLE: ManualMapHandle = { base: 0n, entry: 0n, exceptionTable: 0n, size: 0 };
const flush = () => new Promise<void>((r) => setImmediate(r));
interface Harness {
manager: HookManager;
pipeWatcher: PipeWatcher;
bridgeManager: BridgeManagerSink & { onPidReceiveHealthChanged: ReturnType<typeof vi.fn> };
setProcesses: (next: number[]) => void;
setLivePipes: (next: number[]) => void;
onSessionsChanged: ReturnType<typeof vi.fn>;
clientFactory: ReturnType<typeof vi.fn>;
notifyAccountChange: () => void;
watchAccounts: ReturnType<typeof vi.fn>;
}
function makeManager(
opts: {
processes?: number[];
livePipes?: number[];
detectAccounts?: (pids: readonly number[]) => Promise<AccountLoginState[]>;
} = {},
): Harness {
let pids = opts.processes ?? [];
let live = new Set<number>(opts.livePipes ?? []);
const pipeWatcher = new PipeWatcher({
listProcesses: async () => pids.map((pid) => ({ pid, name: 'qq', path: '' })),
listLivePipes: async () => new Set(live),
intervalMs: 60_000,
});
const clientFactory = vi.fn(() => {
const c = new EventEmitter() as EventEmitter & Partial<QqHookClient>;
(c as any).isClosed = false;
(c as any).isLoggedIn = false;
(c as any).getLoginState = () => ({ loggedIn: false, uin: '0', uinNumber: 0n });
(c as any).connectAll = async () => {
/* succeed without doing anything */
};
(c as any).close = () => {
(c as any).isClosed = true;
};
return c as unknown as QqHookClient;
});
const bridgeManager = {
onPacket: vi.fn(),
onHookLogin: vi.fn(),
onPidDisconnected: vi.fn(),
onPidReceiveHealthChanged: vi.fn(),
} as unknown as BridgeManagerSink & { onPidReceiveHealthChanged: ReturnType<typeof vi.fn> };
const onSessionsChanged = vi.fn();
let accountChanged = () => {};
const watchAccounts = vi.fn((_pids: readonly number[], onChange: () => void) => {
accountChanged = onChange;
return vi.fn();
});
const manager = new HookManager({
bridgeManager,
pipeWatcher,
injector: {
inject: vi.fn(async () => ({
method: 'loadModuleManual' as const,
handle: DUMMY_HANDLE,
})),
unload: vi.fn(async () => {}),
},
makeClient: clientFactory,
listProcesses: async () => pids.map((pid) => ({ pid, name: 'qq', path: '' })),
detectAccounts: opts.detectAccounts ?? (async () => []),
watchAccounts,
onSessionsChanged,
});
return {
manager,
pipeWatcher,
bridgeManager,
onSessionsChanged,
clientFactory,
notifyAccountChange: () => accountChanged(),
watchAccounts,
setProcesses: (next) => {
pids = next;
},
setLivePipes: (next) => {
live = new Set(next);
},
};
}
describe('HookManager.onSessionsChanged', () => {
it('fires when the watcher discovers a new QQ process', async () => {
const ctx = makeManager({ processes: [] });
await ctx.pipeWatcher.start();
expect(ctx.onSessionsChanged).not.toHaveBeenCalled();
ctx.setProcesses([4242]);
await ctx.pipeWatcher.tickNow();
await flush();
expect(ctx.onSessionsChanged).toHaveBeenCalled();
ctx.manager.dispose();
});
it('fires when the watcher reports a process is gone', async () => {
const ctx = makeManager({ processes: [4242] });
await ctx.pipeWatcher.start();
await flush();
ctx.onSessionsChanged.mockClear();
ctx.setProcesses([]);
await ctx.pipeWatcher.tickNow();
await flush();
expect(ctx.onSessionsChanged).toHaveBeenCalled();
ctx.manager.dispose();
});
it('fires when the watcher reports the pipe came up (status: connecting → loaded)', async () => {
const ctx = makeManager({ processes: [4242] });
await ctx.pipeWatcher.start();
await flush();
// Inject so the session is in 'connecting' state; the next pipe-up
// makes attemptConnect succeed and status flips to 'loaded'.
await ctx.manager.loadProcess(4242);
await flush();
ctx.onSessionsChanged.mockClear();
ctx.setLivePipes([4242]);
await ctx.pipeWatcher.tickNow();
await flush();
await flush();
expect(ctx.onSessionsChanged).toHaveBeenCalled();
ctx.manager.dispose();
});
it('publishes an initial timeout and automatically recovers when the channel arrives late', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] });
const ctx = makeManager({ processes: [4242] });
try {
await ctx.manager.loadProcess(4242);
ctx.onSessionsChanged.mockClear();
await vi.advanceTimersByTimeAsync(60_000);
expect(ctx.onSessionsChanged).toHaveBeenCalled();
expect(await ctx.manager.listProcesses()).toEqual([
expect.objectContaining({
pid: 4242,
status: 'error',
injected: true,
error: expect.stringContaining('首次连接超时'),
}),
]);
ctx.onSessionsChanged.mockClear();
ctx.setLivePipes([4242]);
await ctx.pipeWatcher.tickNow();
await flush();
expect(ctx.onSessionsChanged).toHaveBeenCalled();
expect(await ctx.manager.listProcesses()).toEqual([
expect.objectContaining({
pid: 4242,
status: 'loaded',
connected: true,
error: '',
}),
]);
} finally {
ctx.manager.dispose();
vi.useRealTimers();
}
});
it('survives a throwing onSessionsChanged callback — the watcher loop keeps running', async () => {
const ctx = makeManager({ processes: [] });
ctx.onSessionsChanged.mockImplementation(() => {
throw new Error('listener boom');
});
await ctx.pipeWatcher.start();
ctx.setProcesses([4242]);
await expect(ctx.pipeWatcher.tickNow()).resolves.not.toThrow();
await flush();
// A second tick still fires the callback — it wasn't unregistered by
// the prior throw.
ctx.onSessionsChanged.mockClear();
ctx.setProcesses([4242, 5555]);
await ctx.pipeWatcher.tickNow();
await flush();
expect(ctx.onSessionsChanged).toHaveBeenCalled();
ctx.manager.dispose();
});
it('is OPTIONAL — omitting it leaves the manager fully functional', async () => {
const pids = [4242];
const pipeWatcher = new PipeWatcher({
listProcesses: async () => pids.map((pid) => ({ pid, name: 'qq', path: '' })),
listLivePipes: async () => new Set(),
intervalMs: 60_000,
});
const manager = new HookManager({
bridgeManager: {
onPacket: vi.fn(),
onHookLogin: vi.fn(),
onPidDisconnected: vi.fn(),
} as unknown as BridgeManagerSink,
pipeWatcher,
injector: {
inject: vi.fn(async () => ({
method: 'loadModuleManual' as const,
handle: DUMMY_HANDLE,
})),
unload: vi.fn(async () => {}),
},
makeClient: vi.fn(() => {
const c = new EventEmitter() as any;
c.isClosed = false;
c.isLoggedIn = false;
c.getLoginState = () => ({ loggedIn: false, uin: '0', uinNumber: 0n });
c.connectAll = async () => undefined;
c.close = () => {
c.isClosed = true;
};
return c;
}),
listProcesses: async () => pids.map((pid) => ({ pid, name: 'qq', path: '' })),
detectAccounts: async () => [],
watchAccounts: () => () => {},
// onSessionsChanged deliberately omitted
});
await pipeWatcher.start();
await pipeWatcher.tickNow();
await flush();
const list = await manager.listProcesses();
expect(list).toHaveLength(1);
expect(list[0].pid).toBe(4242);
manager.dispose();
});
});
describe('HookManager account re-probe', () => {
it('reuses the account snapshot across ticks and REST reads while all sessions are online (#549)', async () => {
const detectAccounts = vi.fn(async (): Promise<AccountLoginState[]> => [
{ uin: '10001', uid: 'u_a', nickname: 'Alice', pid: 4242 },
{ uin: '10002', uid: 'u_b', nickname: 'Bob', pid: 5151 },
]);
const ctx = makeManager({
processes: [4242, 5151],
livePipes: [4242, 5151],
detectAccounts,
});
const now = vi.spyOn(Date, 'now');
try {
await ctx.pipeWatcher.start();
await flush();
for (const [index, uin] of ['10001', '10002'].entries()) {
const client = ctx.clientFactory.mock.results[index]!.value as EventEmitter;
client.emit('loginState', { loggedIn: true, uin, uinNumber: BigInt(uin) });
}
const initial = await ctx.manager.listProcesses();
expect(initial.map((info) => info.status)).toEqual(['online', 'online']);
const baseline = detectAccounts.mock.calls.length;
ctx.onSessionsChanged.mockClear();
const startedAt = Date.now();
for (let tick = 1; tick <= 10; tick++) {
now.mockReturnValue(startedAt + tick * 6000);
await ctx.pipeWatcher.tickNow();
expect(await ctx.manager.listProcesses()).toEqual(initial);
}
expect(detectAccounts).toHaveBeenCalledTimes(baseline);
expect(ctx.onSessionsChanged).not.toHaveBeenCalled();
} finally {
now.mockRestore();
ctx.manager.dispose();
ctx.pipeWatcher.dispose();
}
});
it.each(['loadProcess', 'unloadProcess', 'refreshProcess'] as const)(
'refreshes account labels on %s even within the probe interval',
async (operation) => {
let nickname = 'Alice';
const ctx = makeManager({
processes: [4242],
detectAccounts: async () => [{ uin: '10001', uid: 'u_a', nickname, pid: 4242 }],
});
try {
expect((await ctx.manager.listProcesses())[0]!.account?.nickname).toBe('Alice');
nickname = 'Updated';
expect((await ctx.manager[operation](4242)).account?.nickname).toBe('Updated');
} finally {
ctx.manager.dispose();
ctx.pipeWatcher.dispose();
}
},
);
it('discards an in-flight account result when an online process switches accounts', async () => {
const stale = Promise.withResolvers<AccountLoginState[]>();
const fresh = Promise.withResolvers<AccountLoginState[]>();
const detectAccounts = vi.fn(async (): Promise<AccountLoginState[]> => [
{ uin: '10001', uid: 'u_a', nickname: 'Alice', pid: 4242 },
]);
const ctx = makeManager({
processes: [4242],
livePipes: [4242],
detectAccounts,
});
try {
await ctx.pipeWatcher.start();
await flush();
const client = ctx.clientFactory.mock.results[0]!.value as EventEmitter;
client.emit('loginState', { loggedIn: true, uin: '10001', uinNumber: 10001n });
await ctx.manager.listProcesses();
detectAccounts.mockImplementationOnce(() => stale.promise);
detectAccounts.mockImplementationOnce(() => fresh.promise);
const refreshing = ctx.manager.refreshProcess(4242);
await flush();
const baseline = detectAccounts.mock.calls.length;
client.emit('loginState', { loggedIn: true, uin: '10002', uinNumber: 10002n });
const snapshot = ctx.manager.listProcesses();
await flush();
ctx.onSessionsChanged.mockClear();
stale.resolve([{ uin: '10003', uid: 'u_c', nickname: 'Stale', pid: 4242 }]);
await flush();
expect(ctx.onSessionsChanged).not.toHaveBeenCalled();
expect(detectAccounts).toHaveBeenCalledTimes(baseline + 1);
fresh.resolve([{ uin: '10002', uid: 'u_b', nickname: 'Bob', pid: 4242 }]);
expect((await snapshot)[0]!.account).toEqual({ uin: '10002', nickname: 'Bob' });
expect((await refreshing).account).toEqual({ uin: '10002', nickname: 'Bob' });
} finally {
stale.resolve([]);
fresh.resolve([]);
ctx.manager.dispose();
ctx.pipeWatcher.dispose();
}
});
it('shares one account probe between watcher ticks and concurrent snapshots', async () => {
const probe = Promise.withResolvers<AccountLoginState[]>();
const detectAccounts = vi.fn(() => probe.promise);
const ctx = makeManager({ processes: [4242], detectAccounts });
try {
await ctx.pipeWatcher.start();
const first = ctx.manager.listProcesses();
const second = ctx.manager.listProcesses();
await flush();
expect(detectAccounts).toHaveBeenCalledTimes(1);
probe.resolve([{ uin: '10001', uid: 'u_a', nickname: 'Alice', pid: 4242 }]);
const snapshots = await Promise.all([first, second]);
expect(snapshots.map((list) => list[0]!.account)).toEqual([
{ uin: '10001', nickname: 'Alice' },
{ uin: '10001', nickname: 'Alice' },
]);
} finally {
probe.resolve([]);
ctx.manager.dispose();
}
});
it.each(['pipe-down', 'client-close', 'logout'] as const)(
're-probes after %s and observes later login data changes',
async (event) => {
let accounts: AccountLoginState[] = [
{ uin: '10001', uid: 'u_a', nickname: 'Alice', pid: 4242 },
];
const detectAccounts = vi.fn(async () => accounts);
const ctx = makeManager({
processes: [4242],
livePipes: [4242],
detectAccounts,
});
const now = vi.spyOn(Date, 'now');
try {
await ctx.pipeWatcher.start();
await flush();
const client = ctx.clientFactory.mock.results[0]!.value as EventEmitter;
client.emit('loginState', { loggedIn: true, uin: '10001', uinNumber: 10001n });
await ctx.manager.listProcesses();
accounts = [];
if (event === 'pipe-down') {
ctx.setLivePipes([]);
await ctx.pipeWatcher.tickNow();
} else if (event === 'client-close') {
client.emit('close');
} else {
client.emit('loginState', { loggedIn: false, uin: '0', uinNumber: 0n });
}
await flush();
expect((await ctx.manager.listProcesses())[0]!.account).toBeNull();
accounts = [{ uin: '10002', uid: 'u_b', nickname: 'Bob', pid: 4242 }];
now.mockReturnValue(Date.now() + 6000);
ctx.notifyAccountChange();
await ctx.pipeWatcher.tickNow();
expect((await ctx.manager.listProcesses())[0]!.account?.uin).toBe('10002');
} finally {
now.mockRestore();
ctx.manager.dispose();
ctx.pipeWatcher.dispose();
}
},
);
it('re-probes when another process appears and when it exits', async () => {
let nickname = 'Alice';
const detectAccounts = vi.fn(async (): Promise<AccountLoginState[]> => [
{ uin: '10001', uid: 'u_a', nickname, pid: 4242 },
{ uin: '10002', uid: 'u_b', nickname: 'Bob', pid: 5151 },
]);
const ctx = makeManager({ processes: [4242], livePipes: [4242], detectAccounts });
try {
await ctx.pipeWatcher.start();
await flush();
const client = ctx.clientFactory.mock.results[0]!.value as EventEmitter;
client.emit('loginState', { loggedIn: true, uin: '10001', uinNumber: 10001n });
await ctx.manager.listProcesses();
nickname = 'Updated';
ctx.setProcesses([4242, 5151]);
await ctx.pipeWatcher.tickNow();
expect((await ctx.manager.listProcesses()).map((info) => info.account)).toEqual([
{ uin: '10001', nickname: 'Updated' },
{ uin: '10002', nickname: 'Bob' },
]);
nickname = 'Final';
ctx.setProcesses([4242]);
await ctx.pipeWatcher.tickNow();
await flush();
const list = await ctx.manager.listProcesses();
expect(list).toHaveLength(1);
expect(list[0]!.account?.nickname).toBe('Final');
} finally {
ctx.manager.dispose();
ctx.pipeWatcher.dispose();
}
});
it('keeps all process labels when an operation reads just one process', async () => {
const accounts: AccountLoginState[] = [
{ uin: '10001', uid: 'u_a', nickname: 'Alice', pid: 4242 },
{ uin: '10002', uid: 'u_b', nickname: 'Bob', pid: 5151 },
];
const detectAccounts = vi.fn(async (pids: readonly number[]) =>
accounts.filter((account) => pids.includes(account.pid!)),
);
const ctx = makeManager({ processes: [4242, 5151], detectAccounts });
try {
await ctx.manager.listProcesses();
expect((await ctx.manager.refreshProcess(4242)).account?.uin).toBe('10001');
const baseline = detectAccounts.mock.calls.length;
ctx.onSessionsChanged.mockClear();
await ctx.pipeWatcher.tickNow();
await flush();
expect(detectAccounts).toHaveBeenCalledTimes(baseline);
expect(ctx.onSessionsChanged).not.toHaveBeenCalled();
expect((await ctx.manager.listProcesses())[1]!.account?.uin).toBe('10002');
} finally {
ctx.manager.dispose();
}
});
it('does not publish a late account result for an exited process', async () => {
const probe = Promise.withResolvers<AccountLoginState[]>();
const ctx = makeManager({ processes: [4242], detectAccounts: () => probe.promise });
try {
await ctx.pipeWatcher.start();
const snapshot = ctx.manager.listProcesses();
await flush();
ctx.setProcesses([]);
await ctx.pipeWatcher.tickNow();
await flush();
ctx.onSessionsChanged.mockClear();
probe.resolve([{ uin: '10001', uid: 'u_a', nickname: 'Alice', pid: 4242 }]);
await flush();
expect(await snapshot).toEqual([]);
expect(ctx.onSessionsChanged).not.toHaveBeenCalled();
expect(await ctx.manager.listProcesses()).toEqual([]);
} finally {
probe.resolve([]);
ctx.manager.dispose();
}
});
// Login database changes can reveal login before the hook is loaded.
it('publishes when a process gains an account label', async () => {
let accounts: AccountLoginState[] = [{ uin: '1', uid: 'u1', nickname: 'A', pid: null }];
const ctx = makeManager({
processes: [4242],
detectAccounts: async () => accounts,
});
await ctx.pipeWatcher.start();
await flush();
ctx.onSessionsChanged.mockClear();
// QQ finished signing in: the account's nt_msg.db is now held by 4242.
accounts = [{ uin: '1', uid: 'u1', nickname: 'A', pid: 4242 }];
ctx.notifyAccountChange();
await flush();
await flush();
expect(ctx.onSessionsChanged).toHaveBeenCalled();
const list = await ctx.manager.listProcesses();
expect(list[0]!.account).toEqual({ uin: '1', nickname: 'A' });
ctx.manager.dispose();
});
it('stays silent while the labels are unchanged', async () => {
const accounts: AccountLoginState[] = [{ uin: '1', uid: 'u1', nickname: 'A', pid: 4242 }];
const detectAccounts = vi.fn(async () => accounts);
const ctx = makeManager({
processes: [4242],
detectAccounts,
});
await ctx.pipeWatcher.start();
await flush();
ctx.onSessionsChanged.mockClear();
ctx.notifyAccountChange();
await flush();
await flush();
// Detection did run again — it just had nothing new to report.
expect(detectAccounts.mock.calls.length).toBeGreaterThan(1);
expect(ctx.onSessionsChanged).not.toHaveBeenCalled();
ctx.manager.dispose();
});
it('ignores accounts that are not signed in on any process', async () => {
let accounts: AccountLoginState[] = [{ uin: '1', uid: 'u1', nickname: 'A', pid: null }];
const ctx = makeManager({
processes: [4242],
detectAccounts: async () => accounts,
});
await ctx.pipeWatcher.start();
await flush();
ctx.onSessionsChanged.mockClear();
// Only the cached-account set changed (another account signed in on
// another machine/user); no process label moved.
accounts = [
{ uin: '1', uid: 'u1', nickname: 'A', pid: null },
{ uin: '2', uid: 'u2', nickname: 'B', pid: null },
];
ctx.notifyAccountChange();
await flush();
await flush();
expect(ctx.onSessionsChanged).not.toHaveBeenCalled();
ctx.manager.dispose();
});
it('does not poll unlabelled, unloaded processes even after the old interval expires', async () => {
const detectAccounts = vi.fn(async () => [] as AccountLoginState[]);
const ctx = makeManager({
processes: [4242],
detectAccounts,
});
const now = vi.spyOn(Date, 'now');
try {
await ctx.manager.listProcesses();
const baseline = detectAccounts.mock.calls.length;
const startedAt = Date.now();
for (let tick = 1; tick <= 10; tick++) {
now.mockReturnValue(startedAt + tick * 6000);
await ctx.pipeWatcher.tickNow();
expect((await ctx.manager.listProcesses())[0]!.account).toBeNull();
}
expect(detectAccounts).toHaveBeenCalledTimes(baseline);
} finally {
now.mockRestore();
ctx.manager.dispose();
ctx.pipeWatcher.dispose();
}
});
it('does not re-probe a mixed set of online and unloaded processes', async () => {
const detectAccounts = vi.fn(async (): Promise<AccountLoginState[]> => [
{ uin: '10001', uid: 'u_a', nickname: 'Alice', pid: 4242 },
]);
const ctx = makeManager({
processes: [4242, 5151],
livePipes: [4242],
detectAccounts,
});
const now = vi.spyOn(Date, 'now');
try {
await ctx.pipeWatcher.start();
await flush();
const client = ctx.clientFactory.mock.results[0]!.value as EventEmitter;
client.emit('loginState', { loggedIn: true, uin: '10001', uinNumber: 10001n });
expect((await ctx.manager.listProcesses()).map((info) => info.status)).toEqual([
'online',
'available',
]);
const baseline = detectAccounts.mock.calls.length;
now.mockReturnValue(Date.now() + 60_000);
await ctx.pipeWatcher.tickNow();
await ctx.manager.listProcesses();
expect(detectAccounts).toHaveBeenCalledTimes(baseline);
} finally {
now.mockRestore();
ctx.manager.dispose();
ctx.pipeWatcher.dispose();
}
});
it('releases file subscriptions when the process set changes and the manager stops', async () => {
const ctx = makeManager({ processes: [4242] });
try {
await ctx.pipeWatcher.start();
expect(ctx.watchAccounts).toHaveBeenLastCalledWith([4242], expect.any(Function));
const firstStop = ctx.watchAccounts.mock.results.at(-1)!.value;
ctx.setProcesses([4242, 5151]);
await ctx.pipeWatcher.tickNow();
expect(firstStop).toHaveBeenCalledOnce();
expect(ctx.watchAccounts).toHaveBeenLastCalledWith([4242, 5151], expect.any(Function));
const secondStop = ctx.watchAccounts.mock.results.at(-1)!.value;
ctx.setProcesses([5151]);
await ctx.pipeWatcher.tickNow();
await flush();
expect(secondStop).toHaveBeenCalledOnce();
expect(ctx.watchAccounts).toHaveBeenLastCalledWith([5151], expect.any(Function));
const finalStop = ctx.watchAccounts.mock.results.at(-1)!.value;
ctx.manager.dispose();
expect(finalStop).toHaveBeenCalledOnce();
} finally {
ctx.manager.dispose();
ctx.pipeWatcher.dispose();
}
});
it('keeps working when detection fails, and reports the failure once', async () => {
const ctx = makeManager({
processes: [4242],
detectAccounts: async () => {
throw new Error('login.db unreadable');
},
});
await ctx.pipeWatcher.start();
await flush();
ctx.onSessionsChanged.mockClear();
await expect(ctx.pipeWatcher.tickNow()).resolves.not.toThrow();
await expect(ctx.pipeWatcher.tickNow()).resolves.not.toThrow();
await flush();
await flush();
// Detection failing is not a label change, so nothing is published.
expect(ctx.onSessionsChanged).not.toHaveBeenCalled();
expect((await ctx.manager.listProcesses())[0]!.account).toBeNull();
ctx.manager.dispose();
});
});
describe('HookManager receive-health wiring (#233)', () => {
it('forwards stale and recovered receive health for the owning PID', async () => {
const ctx = makeManager({ processes: [4242], livePipes: [4242] });
await ctx.pipeWatcher.start();
await ctx.manager.loadProcess(4242);
await ctx.manager.refreshProcess(4242);
await flush();
const client = ctx.clientFactory.mock.results[0]!.value as EventEmitter;
client.emit('loginState', { loggedIn: true, uin: '10001', uinNumber: 10001n });
vi.useFakeTimers({ toFake: ['setTimeout', 'Date'] });
try {
client.emit('packet', {
seq: 1,
error: 0,
cmd: 'trpc.qq_new_tech.status_svc.StatusService.SsoHeartBeat',
uin: '10001',
body: Buffer.alloc(0),
});
vi.advanceTimersByTime(105_000);
expect(ctx.bridgeManager.onPidReceiveHealthChanged).toHaveBeenCalledWith(4242, false);
client.emit('packet', {
seq: 2,
error: 0,
cmd: 'trpc.msg.olpush.OlPushService.MsgPush',
uin: '10001',
body: Buffer.alloc(0),
});
expect(ctx.bridgeManager.onPidReceiveHealthChanged).toHaveBeenLastCalledWith(
4242,
true,
);
} finally {
vi.useRealTimers();
ctx.manager.dispose();
}
});
});
+514
View File
@@ -0,0 +1,514 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EventEmitter } from 'events';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { HookManager, shouldAutoLoadPid } from '../src/hook-manager';
import { PipeWatcher } from '../src/pipe-watcher';
import type { HookInjectResult, ManualMapHandle } from '../src/injector';
import type { BridgeManagerSink } from '../src/hook-manager';
import type { AccountLoginState } from '../src/qq-account';
import type { QqHookClient } from '../src/qq-hook-client';
import {
createLogger,
getLogLevel,
setLogLevel,
subscribeLogs,
type LogEntry,
} from '@snowluma/common/logger';
const DUMMY_HANDLE: ManualMapHandle = { base: 0n, entry: 0n, exceptionTable: 0n, size: 0 };
const flush = () => new Promise<void>((r) => setImmediate(r));
const previousLogLevel = getLogLevel();
function managerTrace(entries: LogEntry[]): LogEntry[] {
return entries.filter((entry) => entry.level === 'trace' && entry.scope === 'Hook');
}
function makeManager(opts: {
autoLoadOnDiscovery?: boolean;
processes?: number[];
onSessionsChanged?: () => void;
inject?: (pid: number) => Promise<HookInjectResult>;
detectAccounts?: (pids: readonly number[]) => Promise<AccountLoginState[]>;
}) {
let pids = opts.processes ?? [];
const live = new Set<number>();
const inject = vi.fn(
opts.inject ??
(async () => ({
method: 'loadModuleManual' as const,
handle: DUMMY_HANDLE,
})),
);
const unload = vi.fn(async () => {});
const pipeWatcher = new PipeWatcher({
listProcesses: async () => pids.map((pid) => ({ pid, name: 'qq', path: '' })),
listLivePipes: async () => new Set(live),
intervalMs: 60_000, // disable internal timer; tests drive ticks manually
});
// FakeClient — never connected; just needs to satisfy EventEmitter +
// a minimal subset of QqHookClient for HookSession.tearDownClient().
const makeClient = vi.fn(() => {
const c = new EventEmitter() as EventEmitter & Partial<QqHookClient>;
(c as any).isClosed = false;
(c as any).isLoggedIn = false;
(c as any).getLoginState = () => ({ loggedIn: false, uin: '0', uinNumber: 0n });
(c as any).connectAll = async () => {
throw new Error('test: never really connect');
};
(c as any).close = () => {
(c as any).isClosed = true;
};
return c as unknown as QqHookClient;
});
const bridgeManager = {
onPacket: vi.fn(),
onHookLogin: vi.fn(),
onPidDisconnected: vi.fn(),
} as unknown as BridgeManagerSink;
const manager = new HookManager({
bridgeManager,
pipeWatcher,
injector: { inject, unload },
makeClient,
autoLoadOnDiscovery: opts.autoLoadOnDiscovery,
onSessionsChanged: opts.onSessionsChanged,
listProcesses: async () => pids.map((pid) => ({ pid, name: 'qq', path: '' })),
// Detection reads QQ's own data directories; tests stay off the host.
detectAccounts: opts.detectAccounts ?? (async () => []),
watchAccounts: () => () => {},
});
return {
manager,
inject,
pipeWatcher,
setProcesses: (next: number[]) => {
pids = next;
},
};
}
afterEach(() => {
setLogLevel(previousLogLevel);
});
describe('HookManager runtime TRACE', () => {
it('records discovery and process-gone facts while unchanged ticks stay silent', async () => {
const entries: LogEntry[] = [];
const sessionsChanged = vi.fn();
setLogLevel('trace');
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
const ctx = makeManager({
autoLoadOnDiscovery: false,
processes: [],
onSessionsChanged: sessionsChanged,
});
try {
await ctx.pipeWatcher.start();
const baseline = managerTrace(entries).length;
await ctx.pipeWatcher.tickNow();
await ctx.pipeWatcher.tickNow();
expect(managerTrace(entries)).toHaveLength(baseline);
ctx.setProcesses([4242]);
await ctx.pipeWatcher.tickNow();
ctx.setProcesses([]);
await ctx.pipeWatcher.tickNow();
await flush();
const facts = managerTrace(entries).filter((entry) =>
entry.message.startsWith('hook_manager_fact '),
);
expect(facts.map((entry) => entry.message)).toEqual([
expect.stringContaining('hook_manager_fact event=process_discovered pid=4242'),
'hook_manager_fact event=process_gone pid=4242 tracked=true',
]);
expect(facts.every((entry) => entry.req !== undefined)).toBe(true);
} finally {
unsubscribe();
ctx.manager.dispose();
}
});
it('records bounded transient retries and recovery as separate attempts', async () => {
const entries: LogEntry[] = [];
setLogLevel('trace');
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
const ctx = makeManager({ autoLoadOnDiscovery: true, processes: [4242] });
ctx.inject
.mockImplementationOnce(() => {
throw new Error(
'target process does not map /lib/x86_64-linux-gnu/libc.so.6 while resolving mmap',
);
})
.mockImplementationOnce(() => ({
method: 'loadModuleManual' as const,
handle: DUMMY_HANDLE,
}));
try {
await ctx.pipeWatcher.start();
await flush();
await flush();
await ctx.pipeWatcher.tickNow();
await flush();
await flush();
const attempts = managerTrace(entries).filter((entry) =>
entry.message.startsWith('hook_autoload_'),
);
expect(attempts.map((entry) => entry.message)).toEqual([
'hook_autoload_start pid=4242 attempt=1 maxAttempts=3',
expect.stringMatching(
/^hook_autoload_terminal pid=4242 attempt=1 outcome=failed reason=retry_pending error=.*elapsedMs=\d+$/,
),
'hook_autoload_start pid=4242 attempt=2 maxAttempts=3',
expect.stringMatching(
/^hook_autoload_terminal pid=4242 attempt=2 outcome=completed reason=recovered state=.*elapsedMs=\d+$/,
),
]);
expect(attempts[0]!.req).toBe(attempts[1]!.req);
expect(attempts[2]!.req).toBe(attempts[3]!.req);
expect(attempts[0]!.req).not.toBe(attempts[2]!.req);
} finally {
unsubscribe();
ctx.manager.dispose();
}
});
it('ends an in-flight auto-load as dropped when a manual load supersedes it', async () => {
const entries: LogEntry[] = [];
let resolveInjection!: (result: HookInjectResult) => void;
const injection = new Promise<HookInjectResult>((resolve) => {
resolveInjection = resolve;
});
setLogLevel('trace');
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
const ctx = makeManager({
autoLoadOnDiscovery: true,
processes: [4242],
inject: () => injection,
});
try {
await ctx.pipeWatcher.start();
await flush();
const manualLoad = ctx.manager.loadProcess(4242);
resolveInjection({ method: 'loadModuleManual', handle: DUMMY_HANDLE });
await manualLoad;
await flush();
const attempts = managerTrace(entries).filter((entry) =>
entry.message.startsWith('hook_autoload_'),
);
expect(attempts.map((entry) => entry.message)).toEqual([
'hook_autoload_start pid=4242 attempt=1 maxAttempts=3',
expect.stringMatching(
/^hook_autoload_terminal pid=4242 attempt=1 outcome=dropped reason=superseded elapsedMs=\d+$/,
),
]);
expect(attempts[0]!.req).toBe(attempts[1]!.req);
} finally {
unsubscribe();
ctx.manager.dispose();
}
});
it.each([
{
name: 'permanent failure',
error: 'ptrace denied',
ticks: 1,
attempts: 1,
reason: 'permanent_failure',
},
{
name: 'retry exhaustion',
error: 'target process does not map /lib/libc.so.6 while resolving mmap',
ticks: 4,
attempts: 3,
reason: 'retry_exhausted',
},
])('records $name terminal', async ({ error, ticks, attempts, reason }) => {
const entries: LogEntry[] = [];
setLogLevel('trace');
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
const ctx = makeManager({ autoLoadOnDiscovery: true, processes: [4242] });
ctx.inject.mockImplementation(() => {
throw new Error(error);
});
try {
await ctx.pipeWatcher.start();
await flush();
await flush();
for (let tick = 0; tick < ticks; tick += 1) {
await ctx.pipeWatcher.tickNow();
await flush();
await flush();
}
const starts = managerTrace(entries).filter((entry) =>
entry.message.startsWith('hook_autoload_start '),
);
const terminals = managerTrace(entries).filter((entry) =>
entry.message.startsWith('hook_autoload_terminal '),
);
expect(starts).toHaveLength(attempts);
expect(terminals).toHaveLength(attempts);
expect(terminals.at(-1)!.message).toContain(`reason=${reason}`);
} finally {
unsubscribe();
ctx.manager.dispose();
}
});
});
describe('HookManager.autoLoadOnDiscovery', () => {
it('does NOT inject on process-discovered when flag is off', async () => {
const ctx = makeManager({ autoLoadOnDiscovery: false, processes: [4242] });
await ctx.pipeWatcher.start();
await flush();
expect(ctx.inject).not.toHaveBeenCalled();
ctx.manager.dispose();
});
it('injects every newly-discovered PID when flag is on', async () => {
const ctx = makeManager({ autoLoadOnDiscovery: true, processes: [4242] });
await ctx.pipeWatcher.start();
// session.load() is queued via a per-session promise chain; flush twice
// so the auto-load runs to completion before we assert.
await flush();
await flush();
expect(ctx.inject).toHaveBeenCalledTimes(1);
expect(ctx.inject).toHaveBeenCalledWith(4242);
// A second PID appearing mid-flight should also trigger an inject.
ctx.setProcesses([4242, 9999]);
await ctx.pipeWatcher.tickNow();
await flush();
await flush();
expect(ctx.inject).toHaveBeenCalledTimes(2);
expect(ctx.inject).toHaveBeenLastCalledWith(9999);
ctx.manager.dispose();
});
it('swallows inject errors so the watcher keeps running', async () => {
const ctx = makeManager({ autoLoadOnDiscovery: true, processes: [4242] });
ctx.inject.mockImplementationOnce(() => {
throw new Error('ptrace denied');
});
await ctx.pipeWatcher.start();
await flush();
await flush();
// Second PID should still be auto-loaded — the failure on 4242 must
// not leak out of the listener and break the watcher's emit loop.
ctx.setProcesses([4242, 9999]);
await ctx.pipeWatcher.tickNow();
await flush();
await flush();
expect(ctx.inject).toHaveBeenCalledTimes(2);
ctx.manager.dispose();
});
it('retries a transient libc mapping failure on the next watcher tick', async () => {
const ctx = makeManager({ autoLoadOnDiscovery: true, processes: [4242] });
ctx.inject.mockImplementationOnce(() => {
throw new Error(
'target process does not map /lib/x86_64-linux-gnu/libc.so.6 while resolving mmap',
);
});
await ctx.pipeWatcher.start();
await flush();
await flush();
expect(ctx.inject).toHaveBeenCalledTimes(1);
await ctx.pipeWatcher.tickNow();
await flush();
await flush();
expect(ctx.inject).toHaveBeenCalledTimes(2);
expect(ctx.inject).toHaveBeenLastCalledWith(4242);
ctx.manager.dispose();
});
it('does not retry permanent auto-load errors', async () => {
const ctx = makeManager({ autoLoadOnDiscovery: true, processes: [4242] });
ctx.inject.mockImplementation(() => {
throw new Error('ptrace denied');
});
await ctx.pipeWatcher.start();
await flush();
await flush();
await ctx.pipeWatcher.tickNow();
await flush();
await flush();
expect(ctx.inject).toHaveBeenCalledTimes(1);
ctx.manager.dispose();
});
it('stops retrying a transient libc mapping failure after three attempts', async () => {
const ctx = makeManager({ autoLoadOnDiscovery: true, processes: [4242] });
ctx.inject.mockImplementation(() => {
throw new Error(
'target process does not map /lib/x86_64-linux-gnu/libc.so.6 while resolving mmap',
);
});
await ctx.pipeWatcher.start();
await flush();
await flush();
for (let tick = 0; tick < 4; tick += 1) {
await ctx.pipeWatcher.tickNow();
await flush();
await flush();
}
expect(ctx.inject).toHaveBeenCalledTimes(3);
ctx.manager.dispose();
});
});
// `shouldAutoLoadPid` reads /proc/<pid>/cmdline directly, so we point
// it at a temporary directory that mimics the procfs layout. This lets
// the tests run on macOS / CI without a live Linux QQ process.
describe('shouldAutoLoadPid', () => {
let tmpProc: string;
const originalReadFileSync = fs.readFileSync;
const log = createLogger('test');
const originalPlatform = process.platform;
const originalAutoLoadAll = process.env.SNOWLUMA_HOOK_AUTOLOAD_ALL;
beforeEach(() => {
tmpProc = fs.mkdtempSync(path.join(os.tmpdir(), 'hookmgr-proc-'));
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
delete process.env.SNOWLUMA_HOOK_AUTOLOAD_ALL;
// Redirect readFileSync('/proc/<pid>/cmdline') to the tmp dir.
(fs.readFileSync as unknown as typeof fs.readFileSync) = ((
p: string,
...rest: unknown[]
) => {
const match = /^\/proc\/(\d+)\/cmdline$/.exec(p);
if (match) {
return originalReadFileSync(
path.join(tmpProc, match[1], 'cmdline'),
...(rest as [BufferEncoding]),
);
}
return originalReadFileSync(p, ...(rest as [BufferEncoding]));
}) as typeof fs.readFileSync;
});
afterEach(() => {
(fs.readFileSync as unknown as typeof fs.readFileSync) = originalReadFileSync;
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
if (originalAutoLoadAll === undefined) {
delete process.env.SNOWLUMA_HOOK_AUTOLOAD_ALL;
} else {
process.env.SNOWLUMA_HOOK_AUTOLOAD_ALL = originalAutoLoadAll;
}
fs.rmSync(tmpProc, { recursive: true, force: true });
});
function writeCmdline(pid: number, args: string[]): void {
const dir = path.join(tmpProc, String(pid));
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'cmdline'), args.join('\0') + '\0');
}
it('allows the QQ main process (no --type=)', () => {
writeCmdline(50, ['qq', '--no-sandbox']);
expect(shouldAutoLoadPid(50, log)).toBe(true);
});
it('rejects Electron zygotes', () => {
writeCmdline(59, ['/opt/QQ/qq', '--type=zygote', '--no-zygote-sandbox', '--no-sandbox']);
expect(shouldAutoLoadPid(59, log)).toBe(false);
});
it('rejects renderer/gpu/utility children', () => {
writeCmdline(70, ['/opt/QQ/qq', '--type=renderer']);
writeCmdline(71, ['/opt/QQ/qq', '--type=gpu-process']);
writeCmdline(72, [
'/opt/QQ/qq',
'--type=utility',
'--utility-sub-type=network.mojom.NetworkService',
]);
expect(shouldAutoLoadPid(70, log)).toBe(false);
expect(shouldAutoLoadPid(71, log)).toBe(false);
expect(shouldAutoLoadPid(72, log)).toBe(false);
});
it('allows everything if SNOWLUMA_HOOK_AUTOLOAD_ALL=1 (escape hatch)', () => {
writeCmdline(59, ['/opt/QQ/qq', '--type=zygote']);
process.env.SNOWLUMA_HOOK_AUTOLOAD_ALL = '1';
expect(shouldAutoLoadPid(59, log)).toBe(true);
});
it('allows on non-linux platforms (filter is a Linux-only workaround)', () => {
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
// No cmdline file needed — we return true before touching procfs.
expect(shouldAutoLoadPid(59, log)).toBe(true);
});
it('allows when /proc is unreadable (dead PID, permission issue)', () => {
expect(shouldAutoLoadPid(99999, log)).toBe(true);
});
});
describe('HookManager — signed-in account labelling', () => {
it('labels each process with the account detected on it', async () => {
const ctx = makeManager({
processes: [4242, 5151],
detectAccounts: async () => [
{ uin: '10001', uid: 'u_a', nickname: 'Alice', pid: 4242 },
{ uin: '10002', uid: 'u_b', nickname: 'Bob', pid: null },
],
});
try {
const list = await ctx.manager.listProcesses();
expect(list.map((info) => [info.pid, info.account])).toEqual([
[4242, { uin: '10001', nickname: 'Alice' }],
[5151, null],
]);
} finally {
ctx.manager.dispose();
}
});
it('leaves processes unlabelled when detection fails', async () => {
const ctx = makeManager({
processes: [4242],
detectAccounts: async () => {
throw new Error('login.db unreadable');
},
});
try {
const list = await ctx.manager.listProcesses();
expect(list[0]!.account).toBeNull();
} finally {
ctx.manager.dispose();
}
});
});
@@ -0,0 +1,70 @@
import { describe, expect, it, vi } from 'vitest';
import { HookPacketClient } from '../src/hook-packet-client';
import {
HookPipeRequestError,
PIPE_STATUS_CONNECTION_UNAVAILABLE,
type QqHookClient,
} from '../src/qq-hook-client';
function fakeClient(send: QqHookClient['send']): QqHookClient {
return {
isLoggedIn: true,
send,
} as unknown as QqHookClient;
}
describe('HookPacketClient outbound health', () => {
it('reports native connection loss once and recovers after a successful request', async () => {
const healthChanged = vi.fn();
const send = vi
.fn<QqHookClient['send']>()
.mockRejectedValueOnce(
new HookPipeRequestError(
'The QQ connection changed. Please restart QQ and try again.',
PIPE_STATUS_CONNECTION_UNAVAILABLE,
1,
),
)
.mockRejectedValueOnce(
new HookPipeRequestError(
'The QQ connection changed. Please restart QQ and try again.',
PIPE_STATUS_CONNECTION_UNAVAILABLE,
2,
),
)
.mockResolvedValueOnce({
requestId: 3,
error: 0,
message: '',
body: Buffer.from('ok'),
});
const sender = new HookPacketClient(fakeClient(send), healthChanged);
await expect(sender.sendPacket('Test.Command', Buffer.alloc(0))).resolves.toMatchObject({
success: false,
gotResponse: false,
});
await sender.sendPacket('Test.Command', Buffer.alloc(0));
await expect(sender.sendPacket('Test.Command', Buffer.alloc(0))).resolves.toMatchObject({
success: true,
gotResponse: true,
});
expect(healthChanged.mock.calls).toEqual([[false], [true]]);
});
it('does not classify ordinary request rejection as connection loss', async () => {
const healthChanged = vi.fn();
const send = vi
.fn<QqHookClient['send']>()
.mockRejectedValue(new HookPipeRequestError('This request is not valid.', -38, 1));
const sender = new HookPacketClient(fakeClient(send), healthChanged);
await expect(sender.sendPacket('Test.Command', Buffer.alloc(0))).resolves.toMatchObject({
success: false,
gotResponse: false,
});
expect(healthChanged).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,115 @@
// E2E for Docker "QQ never logs in + pipe keeps reconnecting" scenario.
//
// Drives the real PipeWatcher + HookManager through repeated same-PID
// pipe up/down cycles via tickNow() and asserts the BridgeManager mock
// is never touched (no login → no Bridge / OneBotInstance / Identity).
import { describe, it, expect, vi } from 'vitest';
import { EventEmitter } from 'events';
import { HookManager } from '../src/hook-manager';
import { PipeWatcher } from '../src/pipe-watcher';
import type { ManualMapHandle } from '../src/injector';
import type { BridgeManagerSink } from '../src/hook-manager';
import type { QqHookClient } from '../src/qq-hook-client';
const DUMMY_HANDLE: ManualMapHandle = { base: 0n, entry: 0n, exceptionTable: 0n, size: 0 };
const flush = () => new Promise<void>((r) => setImmediate(r));
class FakeClient extends EventEmitter {
isClosed = false;
isLoggedIn = false;
async connectAll(_opts: { recv: boolean }) {
if (this.isClosed) {
throw new Error('client is closed');
}
}
getLoginState() {
return { loggedIn: false, uin: '0', uinNumber: 0n };
}
close() {
if (this.isClosed) {
return;
}
this.isClosed = true;
this.emit('close');
}
}
describe('HookManager — Docker reconnect-without-login leak guard', () => {
it('after N pipe up/down cycles: no Bridge calls, no client accumulation', async () => {
const PID = 61;
const liveSet = new Set<number>([PID]);
const clients: FakeClient[] = [];
const pipeWatcher = new PipeWatcher({
listProcesses: async () => [{ pid: PID, name: 'qq', path: '' }],
listLivePipes: async () => new Set(liveSet),
intervalMs: 60_000, // manual ticks only
});
const bridgeManager = {
onPacket: vi.fn(),
onHookLogin: vi.fn(),
onPidDisconnected: vi.fn(),
} as unknown as BridgeManagerSink;
const manager = new HookManager({
bridgeManager,
pipeWatcher,
injector: {
inject: vi.fn(async () => ({
method: 'loadModuleManual' as const,
handle: DUMMY_HANDLE,
})),
unload: vi.fn(async () => {}),
},
makeClient: () => {
const c = new FakeClient();
clients.push(c);
return c as unknown as QqHookClient;
},
autoLoadOnDiscovery: true,
listProcesses: async () => [{ pid: PID, name: 'qq', path: '' }],
detectAccounts: async () => [],
watchAccounts: () => () => {},
});
// Initial discovery + first connect.
await pipeWatcher.start();
await flush();
await flush();
// Drive 50 pipe up/down cycles with the QQ process staying alive but
// the pipe (= QQ control socket) bouncing every "tick".
const CYCLES = 50;
for (let i = 0; i < CYCLES; i++) {
liveSet.delete(PID);
await pipeWatcher.tickNow();
await flush();
liveSet.add(PID);
await pipeWatcher.tickNow();
await flush();
}
// The "QQ never logs in" hypothesis: BridgeManager should never have
// been told about a login or a disconnect.
expect(bridgeManager.onHookLogin).not.toHaveBeenCalled();
expect(bridgeManager.onPidDisconnected).not.toHaveBeenCalled();
expect(bridgeManager.onPacket).not.toHaveBeenCalled();
// Hook layer should hold at most one live client.
const alive = clients.filter((c) => !c.isClosed);
expect(
alive.length,
`live clients after ${CYCLES} cycles: ${alive.length}`,
).toBeLessThanOrEqual(1);
// Total clients minted = roughly one per up cycle. Sanity bound:
// we must have actually exercised the reconnect path.
expect(clients.length).toBeGreaterThan(1);
manager.dispose();
});
});
@@ -0,0 +1,146 @@
// Regression test for the Docker "QQ-never-logs-in + pipe keeps reconnecting" leak hypothesis.
//
// User-reported pattern (Docker, idle login screen):
// 02:41:23 [Hook] pipe connected: PID=61
// 04:37:55 [Hook] pipe connected: PID=61 (same PID, ~2h later)
// 04:40:44 [Hook] pipe connected: PID=61
// 04:43:48 [Hook] pipe connected: PID=61 → eventual OOM-kill
//
// Hypothesis (H2): every onPipeDown → onPipeUp cycle leaks the prior
// QqHookClient (listeners or socket-ref keeping it pinned in memory),
// so repeated reconnect drives unbounded growth.
//
// This test drives N up/down cycles against a HookSession with a FakeClient
// factory and asserts:
// - at most one client is alive at any point
// - every prior client's EventEmitter listener count drops to 0 after teardown
import { describe, it, expect, onTestFinished, vi } from 'vitest';
import { EventEmitter } from 'events';
import { HookSession } from '../src/hook-session';
import type { ManualMapHandle } from '../src/injector';
import type { QqHookClient } from '../src/qq-hook-client';
const DUMMY_HANDLE: ManualMapHandle = { base: 0n, entry: 0n, exceptionTable: 0n, size: 0 };
class FakeClient extends EventEmitter {
isClosed = false;
isLoggedIn = false;
private loginState = { loggedIn: false, uin: '0', uinNumber: 0n };
async connectAll(_opts: { recv: boolean }): Promise<void> {
if (this.isClosed) {
throw new Error('client is closed');
}
}
getLoginState() {
return { ...this.loginState };
}
close(): void {
if (this.isClosed) {
return;
}
this.isClosed = true;
this.emit('close');
}
}
function totalListenerCount(emitter: EventEmitter): number {
return emitter.eventNames().reduce((n, evt) => n + emitter.listenerCount(evt), 0);
}
const flush = () => new Promise<void>((r) => setImmediate(r));
describe('HookSession — pipe reconnect with QQ never logging in', () => {
it('does not accumulate live FakeClient instances across N pipe up/down cycles', async () => {
let pipeLive = true;
const clients: FakeClient[] = [];
const session = new HookSession(61, {
detectLoginUin: async () => null,
injector: {
inject: vi.fn(async () => ({
method: 'loadModuleManual' as const,
handle: DUMMY_HANDLE,
})),
unload: vi.fn(async () => {}),
},
makeClient: () => {
const c = new FakeClient();
clients.push(c);
return c as unknown as QqHookClient;
},
pipeWatcher: { isPipeLive: () => pipeLive },
});
onTestFinished(() => session.dispose());
await session.load();
session.onPipeUp();
await flush();
const CYCLES = 50;
for (let i = 0; i < CYCLES; i++) {
pipeLive = false;
session.onPipeDown();
await flush();
pipeLive = true;
session.onPipeUp();
await flush();
}
const alive = clients.filter((c) => !c.isClosed);
expect.soft(clients.length).toBeGreaterThan(1);
expect(
alive.length,
`expected ≤1 live client after ${CYCLES} reconnects, got ${alive.length}`,
).toBeLessThanOrEqual(1);
});
it('drops all listeners from each torn-down client', async () => {
let pipeLive = true;
const clients: FakeClient[] = [];
const session = new HookSession(61, {
detectLoginUin: async () => null,
injector: {
inject: vi.fn(async () => ({
method: 'loadModuleManual' as const,
handle: DUMMY_HANDLE,
})),
unload: vi.fn(async () => {}),
},
makeClient: () => {
const c = new FakeClient();
clients.push(c);
return c as unknown as QqHookClient;
},
pipeWatcher: { isPipeLive: () => pipeLive },
});
onTestFinished(() => session.dispose());
await session.load();
session.onPipeUp();
await flush();
const CYCLES = 20;
for (let i = 0; i < CYCLES; i++) {
pipeLive = false;
session.onPipeDown();
await flush();
pipeLive = true;
session.onPipeUp();
await flush();
}
// Every client except the current one should have 0 listeners attached.
// (HookSession.tearDownClient does client.removeAllListeners().)
for (let i = 0; i < clients.length - 1; i++) {
const lc = totalListenerCount(clients[i]);
expect(
lc,
`client #${i} of ${clients.length} still has ${lc} listeners after teardown`,
).toBe(0);
}
});
});
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest';
import { resolveHookRuntimeDir } from '../src/hook-runtime-dir';
function procReader(files: Record<string, string>) {
return (file: string): string => {
const value = files[file];
if (value === undefined) {
throw new Error(`missing fixture: ${file}`);
}
return value;
};
}
describe('resolveHookRuntimeDir — Linux target process ownership', () => {
it('uses the QQ process runtime directory instead of the supervisor user directory', () => {
expect(
resolveHookRuntimeDir(46301, {
platform: 'linux',
env: { XDG_RUNTIME_DIR: '/run/user/0' },
ownUid: 0,
readText: procReader({
'/proc/46301/environ': 'HOME=/home/rikka\0XDG_RUNTIME_DIR=/run/user/1000\0',
'/proc/46301/status': 'Name:\tqq\nUid:\t1000\t1000\t1000\t1000\n',
}),
}),
).toBe('/run/user/1000');
});
it('falls back to the QQ process uid when its environment has no runtime directory', () => {
expect(
resolveHookRuntimeDir(46301, {
platform: 'linux',
env: { XDG_RUNTIME_DIR: '/run/user/0' },
ownUid: 0,
readText: procReader({
'/proc/46301/environ': 'HOME=/home/rikka\0',
'/proc/46301/status': 'Name:\tqq\nUid:\t1000\t1000\t1000\t1000\n',
}),
}),
).toBe('/tmp/snowluma-1000');
});
it('fails visibly when target ownership cannot be determined', () => {
expect(() =>
resolveHookRuntimeDir(46301, {
platform: 'linux',
env: { XDG_RUNTIME_DIR: '/run/user/0' },
ownUid: 0,
readText: () => {
throw new Error('permission denied');
},
}),
).toThrow(/cannot resolve hook runtime directory.*permission denied/);
});
it('uses the current convention when the target disappears during discovery', () => {
const gone = Object.assign(new Error('process disappeared'), { code: 'ENOENT' });
expect(
resolveHookRuntimeDir(46301, {
platform: 'linux',
env: { XDG_RUNTIME_DIR: '/run/user/0' },
ownUid: 0,
readText: () => {
throw gone;
},
}),
).toBe('/run/user/0');
});
it('honours an explicit runtime directory inherited by the QQ process', () => {
expect(
resolveHookRuntimeDir(46301, {
platform: 'linux',
env: {},
ownUid: 0,
readText: procReader({
'/proc/46301/environ': 'SNOWLUMA_HOOK_RUNTIME_DIR=/srv/snowluma-hook\0',
'/proc/46301/status': 'Uid:\t1000\t1000\t1000\t1000\n',
}),
}),
).toBe('/srv/snowluma-hook');
});
});
File diff suppressed because it is too large. Load diff
+68
View File
@@ -0,0 +1,68 @@
import { describe, it, expect } from 'vitest';
import { statusFor, type HookFlags } from '../src/hook-status';
import type { HookProcessStatus } from '../src/types';
const F = (o: Partial<HookFlags>): HookFlags => ({
injected: false,
connected: false,
loggedIn: false,
wasLoggedIn: false,
...o,
});
describe('statusFor — settled HookProcessStatus derivation', () => {
it('not injected → available (regardless of other flags)', () => {
expect(statusFor(F({ injected: false }))).toBe('available');
// !injected dominates: stale connected/loggedIn bits must not leak through.
expect(
statusFor(F({ injected: false, connected: true, loggedIn: true, wasLoggedIn: true })),
).toBe('available');
});
it('injected, connected, loggedIn → online', () => {
expect(statusFor(F({ injected: true, connected: true, loggedIn: true }))).toBe('online');
});
it('injected, connected, not loggedIn → loaded', () => {
expect(statusFor(F({ injected: true, connected: true, loggedIn: false }))).toBe('loaded');
});
it('injected, not connected, had logged in → disconnected', () => {
expect(statusFor(F({ injected: true, connected: false, wasLoggedIn: true }))).toBe(
'disconnected',
);
});
it('injected, not connected, never logged in → connecting', () => {
expect(statusFor(F({ injected: true, connected: false, wasLoggedIn: false }))).toBe(
'connecting',
);
});
it('disconnected vs connecting differ ONLY by wasLoggedIn (same injected+!connected)', () => {
const base = { injected: true, connected: false, loggedIn: false };
expect(statusFor(F({ ...base, wasLoggedIn: true }))).toBe('disconnected');
expect(statusFor(F({ ...base, wasLoggedIn: false }))).toBe('connecting');
});
it('covers exactly the five settled states across the flag space', () => {
const seen = new Set<HookProcessStatus>();
for (const injected of [false, true]) {
for (const connected of [false, true]) {
for (const loggedIn of [false, true]) {
for (const wasLoggedIn of [false, true]) {
seen.add(statusFor({ injected, connected, loggedIn, wasLoggedIn }));
}
}
}
}
expect([...seen].sort()).toEqual(
['available', 'connecting', 'disconnected', 'loaded', 'online'].sort(),
);
// The two out-of-band states are never produced by the pure derivation.
expect(seen.has('loading')).toBe(false);
expect(seen.has('error')).toBe(false);
});
});
+426
View File
@@ -0,0 +1,426 @@
import path from 'path';
import { fileURLToPath } from 'url';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const { existsSyncMock, listPidsMock } = vi.hoisted(() => ({
existsSyncMock: vi.fn<(target: string) => boolean>(() => false),
listPidsMock: vi.fn(() => new Set<number>()),
}));
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
return {
...actual,
existsSync: (target: Parameters<typeof actual.existsSync>[0]) =>
existsSyncMock(String(target)),
};
});
vi.mock('../src/qq-hook-client', () => ({
listSnowlumaPipePidsSync: listPidsMock,
}));
const NATIVE_PATH_DIR = path.dirname(
fileURLToPath(new URL('../../common/src/native-path.ts', import.meta.url)),
);
const ORIGINAL_PLATFORM = process.platform;
const ORIGINAL_ARCH = process.arch;
const HANDLE = {
base: 0x7fff0000n,
entry: 0x7fff1234n,
exceptionTable: 0x7fff5678n,
size: 8192,
};
function setPlatform(platform: NodeJS.Platform, arch: string): void {
Object.defineProperty(process, 'platform', { value: platform, configurable: true });
Object.defineProperty(process, 'arch', { value: arch, configurable: true });
}
function restorePlatform(): void {
Object.defineProperty(process, 'platform', { value: ORIGINAL_PLATFORM, configurable: true });
Object.defineProperty(process, 'arch', { value: ORIGINAL_ARCH, configurable: true });
}
function nativeSearchDirs(): string[] {
return [
path.resolve(NATIVE_PATH_DIR, 'native'),
path.resolve(NATIVE_PATH_DIR, '..', '..', 'runtime', 'native'),
];
}
function nativeCandidate(fileName: string, dirIndex: number): string {
return path.join(nativeSearchDirs()[dirIndex]!, fileName);
}
function allowExisting(paths: readonly string[]): void {
const allowed = new Set(paths);
existsSyncMock.mockImplementation((target) => allowed.has(target));
}
function fakeAddon(
overrides: {
getAllMainProcessAsync?: number[];
loadModuleManualAsync?: (pid: number, dylibPath: string) => Promise<typeof HANDLE>;
unloadModuleManualAsync?: (pid: number, handle: typeof HANDLE) => Promise<void>;
} = {},
) {
return {
getAllMainProcessAsync: vi.fn(async () => overrides.getAllMainProcessAsync ?? []),
loadModuleManualAsync: vi.fn(overrides.loadModuleManualAsync ?? (async () => HANDLE)),
unloadModuleManualAsync: vi.fn(
overrides.unloadModuleManualAsync ?? (async () => undefined),
),
};
}
function installDlopen(addon: object) {
return vi.spyOn(process, 'dlopen').mockImplementation((mod) => {
(mod as { exports: object }).exports = addon;
});
}
async function loadInjector() {
return import('../src/injector');
}
beforeEach(() => {
existsSyncMock.mockReset();
existsSyncMock.mockReturnValue(false);
listPidsMock.mockReset();
listPidsMock.mockReturnValue(new Set());
vi.resetModules();
});
afterEach(() => {
restorePlatform();
vi.restoreAllMocks();
});
describe('resolveHookNativePath', () => {
it('returns null after probing every search directory', async () => {
setPlatform('linux', 'x64');
const probed: string[] = [];
existsSyncMock.mockImplementation((target) => {
probed.push(target);
return false;
});
const { resolveHookNativePath } = await loadInjector();
expect(resolveHookNativePath('node')).toBeNull();
expect(probed).toEqual([
nativeCandidate('snowluma-linux-x64.node', 0),
nativeCandidate('snowluma-linux-x64.node', 1),
]);
});
it('returns the first existing candidate and stops scanning', async () => {
setPlatform('linux', 'x64');
const winner = nativeCandidate('snowluma-linux-x64.node', 0);
const later = nativeCandidate('snowluma-linux-x64.node', 1);
const probed: string[] = [];
existsSyncMock.mockImplementation((target) => {
probed.push(target);
return target === winner || target === later;
});
const { resolveHookNativePath } = await loadInjector();
expect(resolveHookNativePath('node')).toBe(winner);
expect(probed).toEqual([winner]);
});
it('skips missing earlier directories for the injectable .so', async () => {
setPlatform('linux', 'arm64');
const winner = nativeCandidate('snowluma-linux-arm64.so', 1);
allowExisting([winner]);
const { resolveHookNativePath } = await loadInjector();
expect(resolveHookNativePath('so')).toBe(winner);
});
it('resolves snowluma-win32-x64.dll on win32 x64', async () => {
setPlatform('win32', 'x64');
const winner = nativeCandidate('snowluma-win32-x64.dll', 1);
allowExisting([winner]);
const { resolveHookNativePath } = await loadInjector();
expect(resolveHookNativePath('dll')).toBe(winner);
});
it('uses the generic platform-arch name on win32 arm64', async () => {
setPlatform('win32', 'arm64');
const winner = nativeCandidate('snowluma-win32-arm64.node', 1);
allowExisting([winner]);
const { resolveHookNativePath } = await loadInjector();
expect(resolveHookNativePath('node')).toBe(winner);
});
it('does not load an unrelated native addon from the working directory', async () => {
setPlatform('darwin', 'arm64');
vi.spyOn(process, 'cwd').mockReturnValue('/tmp/sl-injector-cwd');
const winner = path.join(
'/tmp/sl-injector-cwd',
'dist',
'native',
'snowluma-darwin-arm64.node',
);
allowExisting([winner]);
const { resolveHookNativePath } = await loadInjector();
expect(resolveHookNativePath('node')).toBeNull();
});
});
describe('getNativeHookAddon / getNativeHookLoadError', () => {
it('starts with a null load error before any probe', async () => {
const { getNativeHookLoadError } = await loadInjector();
expect(getNativeHookLoadError()).toBeNull();
});
it('returns null and records a missing-binary error', async () => {
setPlatform('linux', 'x64');
const dlopen = vi.spyOn(process, 'dlopen');
const { getNativeHookAddon, getNativeHookLoadError } = await loadInjector();
expect(getNativeHookAddon()).toBeNull();
expect(getNativeHookLoadError()).toBe('No hook native addon found for linux-x64');
expect(dlopen).not.toHaveBeenCalled();
});
it('dlopens the resolved .node once and caches the addon', async () => {
setPlatform('linux', 'x64');
const nodePath = nativeCandidate('snowluma-linux-x64.node', 0);
allowExisting([nodePath]);
const addon = fakeAddon();
const dlopen = installDlopen(addon);
const { getNativeHookAddon, getNativeHookLoadError } = await loadInjector();
const first = getNativeHookAddon();
const second = getNativeHookAddon();
expect(first).toBe(addon);
expect(second).toBe(addon);
expect(getNativeHookLoadError()).toBeNull();
expect(dlopen).toHaveBeenCalledTimes(1);
expect(dlopen.mock.calls[0]![1]).toBe(nodePath);
});
it('stores Error.message when process.dlopen throws', async () => {
setPlatform('darwin', 'arm64');
allowExisting([nativeCandidate('snowluma-darwin-arm64.node', 0)]);
vi.spyOn(process, 'dlopen').mockImplementation(() => {
throw new Error('not a valid Node addon');
});
const { getNativeHookAddon, getNativeHookLoadError } = await loadInjector();
expect(getNativeHookAddon()).toBeNull();
expect(getNativeHookLoadError()).toBe('not a valid Node addon');
});
it('stringifies a non-Error dlopen failure', async () => {
setPlatform('linux', 'x64');
allowExisting([nativeCandidate('snowluma-linux-x64.node', 0)]);
vi.spyOn(process, 'dlopen').mockImplementation(() => {
throw 42;
});
const { getNativeHookAddon, getNativeHookLoadError } = await loadInjector();
expect(getNativeHookAddon()).toBeNull();
expect(getNativeHookLoadError()).toBe('42');
});
it('retries after a failed load when a binary later appears', async () => {
setPlatform('linux', 'x64');
const nodePath = nativeCandidate('snowluma-linux-x64.node', 1);
const addon = fakeAddon();
installDlopen(addon);
const { getNativeHookAddon, getNativeHookLoadError } = await loadInjector();
expect(getNativeHookAddon()).toBeNull();
expect(getNativeHookLoadError()).toBe('No hook native addon found for linux-x64');
allowExisting([nodePath]);
expect(getNativeHookAddon()).toBe(addon);
expect(getNativeHookLoadError()).toBeNull();
});
});
describe('listHookProcesses', () => {
it('rejects an obsolete addon without calling its synchronous enumerator', async () => {
setPlatform('win32', 'x64');
allowExisting([nativeCandidate('snowluma-win32-x64.node', 0)]);
const getAllMainProcess = vi.fn(() => [9]);
installDlopen({ getAllMainProcess });
const { listHookProcesses } = await loadInjector();
await expect(listHookProcesses()).rejects.toThrow('getAllMainProcessAsync');
expect(getAllMainProcess).not.toHaveBeenCalled();
});
it('on darwin maps sorted pipe pids without opening the native addon', async () => {
setPlatform('darwin', 'arm64');
listPidsMock.mockReturnValue(new Set([42, 7, 15]));
allowExisting([nativeCandidate('snowluma-darwin-arm64.node', 0)]);
const dlopen = installDlopen(fakeAddon({ getAllMainProcessAsync: [99] }));
const { listHookProcesses } = await loadInjector();
expect(await listHookProcesses()).toEqual([
{ pid: 7, name: 'qq', path: '' },
{ pid: 15, name: 'qq', path: '' },
{ pid: 42, name: 'qq', path: '' },
]);
expect(listPidsMock).toHaveBeenCalledTimes(1);
expect(dlopen).not.toHaveBeenCalled();
});
it('on darwin returns an empty list when no control sockets exist', async () => {
setPlatform('darwin', 'arm64');
listPidsMock.mockReturnValue(new Set());
const { listHookProcesses } = await loadInjector();
expect(await listHookProcesses()).toEqual([]);
});
it('reports a missing addon instead of an empty process set', async () => {
setPlatform('linux', 'x64');
const { listHookProcesses } = await loadInjector();
await expect(listHookProcesses()).rejects.toThrow(
'No hook native addon found for linux-x64',
);
});
it('on linux dedupes, drops non-positive/non-integer pids, and sorts', async () => {
setPlatform('linux', 'x64');
const nodePath = nativeCandidate('snowluma-linux-x64.node', 0);
allowExisting([nodePath]);
installDlopen(
fakeAddon({
getAllMainProcessAsync: [8, 0, 8, -1, 3.5, 2, Number.NaN, 2],
}),
);
const { listHookProcesses } = await loadInjector();
expect(await listHookProcesses()).toEqual([
{ pid: 2, name: 'qq', path: '' },
{ pid: 8, name: 'qq', path: '' },
]);
});
it('on win32 names processes QQ.exe', async () => {
setPlatform('win32', 'x64');
allowExisting([nativeCandidate('snowluma-win32-x64.node', 0)]);
installDlopen(fakeAddon({ getAllMainProcessAsync: [9] }));
const { listHookProcesses } = await loadInjector();
expect(await listHookProcesses()).toEqual([{ pid: 9, name: 'QQ.exe', path: '' }]);
});
});
describe('injectHookProcess', () => {
it('throws the missing-addon load error', async () => {
setPlatform('linux', 'x64');
const { injectHookProcess } = await loadInjector();
await expect(injectHookProcess(4242)).rejects.toThrow(
'No hook native addon found for linux-x64',
);
});
it('throws when the addon exists but the injectable is missing', async () => {
setPlatform('linux', 'x64');
allowExisting([nativeCandidate('snowluma-linux-x64.node', 0)]);
installDlopen(fakeAddon());
const { injectHookProcess } = await loadInjector();
await expect(injectHookProcess(4242)).rejects.toThrow('No hook so found for linux-x64');
});
it('awaits native asynchronous injection', async () => {
setPlatform('linux', 'x64');
const nodePath = nativeCandidate('snowluma-linux-x64.node', 0);
const soPath = nativeCandidate('snowluma-linux-x64.so', 0);
allowExisting([nodePath, soPath]);
const addon = fakeAddon({
loadModuleManualAsync: () => Promise.resolve(HANDLE),
});
installDlopen(addon);
const { injectHookProcess } = await loadInjector();
await expect(injectHookProcess(4242)).resolves.toEqual({
method: 'loadModuleManual',
handle: HANDLE,
});
expect(addon.loadModuleManualAsync).toHaveBeenCalledTimes(1);
expect(addon.loadModuleManualAsync).toHaveBeenCalledWith(4242, soPath);
});
it('injects snowluma-win32-x64.dll on win32 x64', async () => {
setPlatform('win32', 'x64');
const nodePath = nativeCandidate('snowluma-win32-x64.node', 0);
const dllPath = nativeCandidate('snowluma-win32-x64.dll', 0);
allowExisting([nodePath, dllPath]);
const addon = fakeAddon({ loadModuleManualAsync: async () => HANDLE });
installDlopen(addon);
const { injectHookProcess } = await loadInjector();
await expect(injectHookProcess(5150)).resolves.toEqual({
method: 'loadModuleManual',
handle: HANDLE,
});
expect(addon.loadModuleManualAsync).toHaveBeenCalledWith(5150, dllPath);
});
it('propagates a rejected loadModuleManual', async () => {
setPlatform('linux', 'x64');
allowExisting([
nativeCandidate('snowluma-linux-x64.node', 0),
nativeCandidate('snowluma-linux-x64.so', 0),
]);
installDlopen(
fakeAddon({
loadModuleManualAsync: () => Promise.reject(new Error('map failed')),
}),
);
const { injectHookProcess } = await loadInjector();
await expect(injectHookProcess(7)).rejects.toThrow('map failed');
});
});
describe('unloadHookProcess', () => {
it('propagates native unload rejection', async () => {
setPlatform('linux', 'x64');
allowExisting([nativeCandidate('snowluma-linux-x64.node', 0)]);
installDlopen(
fakeAddon({
unloadModuleManualAsync: async () => {
throw new Error('unload failed');
},
}),
);
const { unloadHookProcess } = await loadInjector();
await expect(unloadHookProcess(4242, HANDLE)).rejects.toThrow('unload failed');
});
it('throws the missing-addon load error', async () => {
setPlatform('linux', 'x64');
const { unloadHookProcess } = await loadInjector();
await expect(unloadHookProcess(4242, HANDLE)).rejects.toThrow(
'No hook native addon found for linux-x64',
);
});
it('forwards pid and handle to unloadModuleManual', async () => {
setPlatform('linux', 'x64');
allowExisting([nativeCandidate('snowluma-linux-x64.node', 0)]);
const addon = fakeAddon();
installDlopen(addon);
const { unloadHookProcess } = await loadInjector();
await expect(unloadHookProcess(4242, HANDLE)).resolves.toBeUndefined();
expect(addon.unloadModuleManualAsync).toHaveBeenCalledTimes(1);
expect(addon.unloadModuleManualAsync).toHaveBeenCalledWith(4242, HANDLE);
});
});
@@ -0,0 +1,30 @@
import { afterEach, describe, expect, it } from 'vitest';
import { QqHookClient } from '../src/qq-hook-client';
import { packetTimeoutMs } from '../src/packet-timeout';
const PACKET = 'SNOWLUMA_PACKET_TIMEOUT_MS';
const ACK = 'SNOWLUMA_HOOK_ACK_TIMEOUT_MS';
afterEach(() => {
delete process.env[PACKET];
delete process.env[ACK];
});
describe('packet and hook ack timeouts', () => {
it('keeps the 15 second packet default', () => {
expect(packetTimeoutMs({})).toBe(15_000);
});
it('reads SNOWLUMA_PACKET_TIMEOUT_MS', () => {
expect(packetTimeoutMs({ [PACKET]: '8000' })).toBe(8000);
expect(packetTimeoutMs({ [PACKET]: '0' })).toBe(15_000);
});
it('uses SNOWLUMA_HOOK_ACK_TIMEOUT_MS when the client is not given one', () => {
process.env[ACK] = '1200';
const client = new QqHookClient(1, { runtimeDir: '/tmp' });
expect(client.defaultAckTimeoutMs).toBe(1200);
const explicit = new QqHookClient(1, { runtimeDir: '/tmp', ackTimeoutMs: 50 });
expect(explicit.defaultAckTimeoutMs).toBe(50);
});
});
+476
View File
@@ -0,0 +1,476 @@
import { afterEach, describe, it, expect, vi } from 'vitest';
import { createLogger } from '@snowluma/common/logger';
import { PipeWatcher } from '../src/pipe-watcher';
import type { HookProcessBaseInfo } from '../src/injector';
function setupWatcher(initial: { processes: HookProcessBaseInfo[]; live: Set<number> }) {
let processes = initial.processes;
let live = initial.live;
const watcher = new PipeWatcher({
listProcesses: async () => processes,
// Defensively copy so the watcher can't mutate the test's reference.
listLivePipes: async () => new Set(live),
intervalMs: 60_000, // disable the internal timer; tests drive ticks manually
});
return {
watcher,
setProcesses: (p: HookProcessBaseInfo[]) => {
processes = p;
},
setLive: (s: Set<number>) => {
live = s;
},
};
}
type EventRow = [string, unknown];
function captureEvents(watcher: PipeWatcher): EventRow[] {
const events: EventRow[] = [];
watcher.on('process-discovered', (info) =>
events.push(['discovered', (info as HookProcessBaseInfo).pid]),
);
watcher.on('process-gone', (pid) => events.push(['gone', pid]));
watcher.on('pipe-up', (pid) => events.push(['up', pid]));
watcher.on('pipe-down', (pid) => events.push(['down', pid]));
return events;
}
describe('PipeWatcher', () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('waits for a fresh scan when another tick is already reading processes', async () => {
let release!: (processes: HookProcessBaseInfo[]) => void;
const pending = new Promise<HookProcessBaseInfo[]>((resolve) => {
release = resolve;
});
const latest = [{ pid: 1234, name: 'QQ.exe', path: '' }];
const listProcesses = vi.fn().mockReturnValueOnce(pending).mockResolvedValue(latest);
const watcher = new PipeWatcher({
listProcesses,
listLivePipes: async () => new Set([1234]),
});
const first = watcher.tickNow();
let completed = false;
const second = watcher.tickNow().then(() => {
completed = true;
});
await Promise.resolve();
expect(completed).toBe(false);
release([]);
await Promise.all([first, second]);
expect(listProcesses).toHaveBeenCalledTimes(2);
expect(watcher.isPipeLive(1234)).toBe(true);
watcher.dispose();
});
it.each(['processes', 'pipes'] as const)(
'discards a pending %s scan after stop',
async (stage) => {
let release!: () => void;
let entered!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const started = new Promise<void>((resolve) => {
entered = resolve;
});
const processes = [{ pid: 1234, name: 'QQ.exe', path: '' }];
const listProcesses = vi.fn(async () => {
if (stage === 'processes') {
entered();
await gate;
}
return processes;
});
const listLivePipes = vi.fn(async () => {
if (stage === 'pipes') {
entered();
await gate;
}
return new Set([1234]);
});
const watcher = new PipeWatcher({ listProcesses, listLivePipes });
const events = captureEvents(watcher);
const tick = vi.fn();
watcher.on('tick', tick);
const reading = watcher.start();
await started;
const queued = watcher.tickNow();
watcher.stop();
release();
await Promise.all([reading, queued]);
await watcher.tickNow();
watcher.wake();
expect(events).toEqual([]);
expect(tick).not.toHaveBeenCalled();
expect(watcher.isProcessAlive(1234)).toBe(false);
expect(listProcesses).toHaveBeenCalledOnce();
expect(listLivePipes).toHaveBeenCalledTimes(stage === 'pipes' ? 1 : 0);
watcher.dispose();
},
);
it('keeps a wake requested by a tick listener ahead of the regular interval', async () => {
vi.useFakeTimers();
const listProcesses = vi.fn(async () => []);
const watcher = new PipeWatcher({
listProcesses,
listLivePipes: async () => new Set(),
intervalMs: 60_000,
});
watcher.once('tick', () => watcher.wake());
await watcher.start();
await vi.advanceTimersByTimeAsync(1);
expect(listProcesses).toHaveBeenCalledTimes(2);
watcher.dispose();
});
it('reports a scheduled tick failure and continues polling', async () => {
vi.useFakeTimers();
const listProcesses = vi.fn(async () => []);
const log = createLogger('PipeWatcherTest');
const warn = vi.spyOn(log, 'warn').mockImplementation(() => {});
const watcher = new PipeWatcher({
listProcesses,
listLivePipes: async () => new Set(),
intervalMs: 250,
log,
});
try {
await watcher.start();
watcher.once('tick', () => {
throw new Error('subscriber failed');
});
await vi.advanceTimersByTimeAsync(250);
expect(warn).toHaveBeenCalledWith('poll failed: %s', 'subscriber failed');
await vi.advanceTimersByTimeAsync(250);
expect(listProcesses).toHaveBeenCalledTimes(3);
} finally {
watcher.dispose();
}
});
it('publishes the complete snapshot before notifying subscribers', async () => {
const ctx = setupWatcher({
processes: [{ pid: 1234, name: 'QQ.exe', path: '' }],
live: new Set([1234]),
});
const snapshots: boolean[][] = [];
const observe = () =>
snapshots.push([ctx.watcher.isProcessAlive(1234), ctx.watcher.isPipeLive(1234)]);
ctx.watcher.on('process-discovered', observe);
ctx.watcher.on('pipe-up', observe);
ctx.watcher.on('pipe-down', observe);
ctx.watcher.on('process-gone', observe);
await ctx.watcher.tickNow();
ctx.setProcesses([]);
ctx.setLive(new Set());
await ctx.watcher.tickNow();
expect(snapshots).toEqual([
[true, true],
[true, true],
[false, false],
[false, false],
]);
ctx.watcher.dispose();
});
it('stops emitting the current diff when a subscriber stops the watcher', async () => {
const ctx = setupWatcher({
processes: [{ pid: 1234, name: 'QQ.exe', path: '' }],
live: new Set([1234]),
});
ctx.watcher.on('process-discovered', () => ctx.watcher.stop());
const up = vi.fn();
const tick = vi.fn();
ctx.watcher.on('pipe-up', up);
ctx.watcher.on('tick', tick);
await ctx.watcher.tickNow();
expect(up).not.toHaveBeenCalled();
expect(tick).not.toHaveBeenCalled();
ctx.watcher.dispose();
});
it('passes the current process snapshot to pipe discovery', async () => {
const processes = [
{ pid: 1234, name: 'qq', path: '/opt/QQ/qq' },
{ pid: 5678, name: 'qq', path: '/opt/QQ/qq' },
];
let observed: readonly HookProcessBaseInfo[] | undefined;
const watcher = new PipeWatcher({
listProcesses: async () => processes,
listLivePipes: async (current) => {
observed = current;
return new Set();
},
intervalMs: 60_000,
});
await watcher.start();
expect(observed).toEqual(processes);
watcher.stop();
});
it('start() emits process-discovered + pipe-up for live PIDs on first tick', async () => {
const ctx = setupWatcher({
processes: [{ pid: 1234, name: 'QQ.exe', path: '' }],
live: new Set([1234]),
});
const events = captureEvents(ctx.watcher);
await ctx.watcher.start();
expect(events).toEqual([
['discovered', 1234],
['up', 1234],
]);
expect(ctx.watcher.isPipeLive(1234)).toBe(true);
expect(ctx.watcher.isProcessAlive(1234)).toBe(true);
ctx.watcher.stop();
});
it('pipe-down emitted when a previously-live pipe disappears', async () => {
const ctx = setupWatcher({
processes: [{ pid: 1234, name: 'QQ.exe', path: '' }],
live: new Set([1234]),
});
await ctx.watcher.start();
const events = captureEvents(ctx.watcher);
ctx.setLive(new Set());
await ctx.watcher.tickNow();
expect(events).toEqual([['down', 1234]]);
expect(ctx.watcher.isPipeLive(1234)).toBe(false);
ctx.watcher.stop();
});
it('process-gone + pipe-down both emitted (pipe-down first) when QQ.exe is killed with a live pipe', async () => {
const ctx = setupWatcher({
processes: [{ pid: 1234, name: 'QQ.exe', path: '' }],
live: new Set([1234]),
});
await ctx.watcher.start();
const events = captureEvents(ctx.watcher);
ctx.setProcesses([]);
ctx.setLive(new Set());
await ctx.watcher.tickNow();
expect(events).toEqual([
['down', 1234],
['gone', 1234],
]);
ctx.watcher.stop();
});
it('ignores live pipes whose process is not running', async () => {
const ctx = setupWatcher({
processes: [],
live: new Set([9999]),
});
const events = captureEvents(ctx.watcher);
await ctx.watcher.start();
expect(events).toEqual([]);
expect(ctx.watcher.isPipeLive(9999)).toBe(false);
ctx.watcher.stop();
});
it('emits pipe-up again after a pipe-down → pipe-up cycle', async () => {
const ctx = setupWatcher({
processes: [{ pid: 1234, name: 'QQ.exe', path: '' }],
live: new Set(),
});
await ctx.watcher.start();
const events = captureEvents(ctx.watcher);
ctx.setLive(new Set([1234]));
await ctx.watcher.tickNow();
expect(events).toEqual([['up', 1234]]);
ctx.setLive(new Set());
await ctx.watcher.tickNow();
expect(events).toEqual([
['up', 1234],
['down', 1234],
]);
ctx.setLive(new Set([1234]));
await ctx.watcher.tickNow();
expect(events).toEqual([
['up', 1234],
['down', 1234],
['up', 1234],
]);
ctx.watcher.stop();
});
it('does not double-emit on idempotent ticks (no state change)', async () => {
const ctx = setupWatcher({
processes: [{ pid: 1234, name: 'QQ.exe', path: '' }],
live: new Set([1234]),
});
await ctx.watcher.start();
const events = captureEvents(ctx.watcher);
await ctx.watcher.tickNow();
await ctx.watcher.tickNow();
expect(events).toEqual([]);
ctx.watcher.stop();
});
it('emits "tick" after every poll', async () => {
const ctx = setupWatcher({ processes: [], live: new Set() });
let ticks = 0;
ctx.watcher.on('tick', () => {
ticks++;
});
await ctx.watcher.start();
expect(ticks).toBe(1);
await ctx.watcher.tickNow();
expect(ticks).toBe(2);
ctx.watcher.stop();
});
it('wake() pulls the next tick forward', async () => {
const ctx = setupWatcher({
processes: [{ pid: 1234, name: 'QQ.exe', path: '' }],
live: new Set(),
});
await ctx.watcher.start();
const events = captureEvents(ctx.watcher);
// Wait on 'tick' rather than guessing how many event-loop turns
// setTimeout(0) needs (it's clamped to ~1ms and races setImmediate).
const nextTick = new Promise<void>((resolve) => ctx.watcher.once('tick', () => resolve()));
ctx.setLive(new Set([1234]));
ctx.watcher.wake();
await nextTick;
expect(events).toEqual([['up', 1234]]);
ctx.watcher.stop();
});
it('survives a throwing listProcesses without crashing the watcher', async () => {
let processes: HookProcessBaseInfo[] = [];
let live = new Set<number>();
let shouldThrow = true;
const watcher = new PipeWatcher({
listProcesses: async () => {
if (shouldThrow) {
throw new Error('boom');
}
return processes;
},
listLivePipes: async () => new Set(live),
intervalMs: 60_000,
});
await watcher.start();
expect(watcher.isProcessAlive(1234)).toBe(false);
shouldThrow = false;
processes = [{ pid: 1234, name: 'QQ.exe', path: '' }];
live = new Set([1234]);
const events = captureEvents(watcher);
await watcher.tickNow();
expect(events).toEqual([
['discovered', 1234],
['up', 1234],
]);
watcher.stop();
});
it('preserves live sessions when pipe discovery temporarily fails', async () => {
let processes = [{ pid: 1234, name: 'QQ.exe', path: '' }];
let failPipeDiscovery = false;
const watcher = new PipeWatcher({
listProcesses: async () => processes,
listLivePipes: async () => {
if (failPipeDiscovery) {
throw new Error('permission denied');
}
return new Set([1234]);
},
intervalMs: 60_000,
});
await watcher.start();
expect(watcher.isPipeLive(1234)).toBe(true);
const events = captureEvents(watcher);
failPipeDiscovery = true;
await watcher.tickNow();
expect(events).toEqual([]);
expect(watcher.isProcessAlive(1234)).toBe(true);
expect(watcher.isPipeLive(1234)).toBe(true);
processes = [];
await watcher.tickNow();
expect(events).toEqual([
['down', 1234],
['gone', 1234],
]);
expect(watcher.isProcessAlive(1234)).toBe(false);
expect(watcher.isPipeLive(1234)).toBe(false);
watcher.stop();
});
it('UNKNOWN (null) enumeration keeps prior state — no false process-gone (issue #158)', async () => {
let result: HookProcessBaseInfo[] | null = [{ pid: 1234, name: 'qq', path: '' }];
let live = new Set<number>([1234]);
const watcher = new PipeWatcher({
listProcesses: async () => result,
listLivePipes: async () => new Set(live),
intervalMs: 60_000,
});
await watcher.start();
expect(watcher.isProcessAlive(1234)).toBe(true);
expect(watcher.isPipeLive(1234)).toBe(true);
const events = captureEvents(watcher);
// Enumeration goes UNKNOWN (e.g. native /proc walk timed out during a QQ
// hot-update). Prior process + pipe state MUST be preserved — no teardown.
result = null;
await watcher.tickNow();
expect(events).toEqual([]);
expect(watcher.isProcessAlive(1234)).toBe(true);
expect(watcher.isPipeLive(1234)).toBe(true);
// Enumeration recovers with the same set → still no churn.
result = [{ pid: 1234, name: 'qq', path: '' }];
await watcher.tickNow();
expect(events).toEqual([]);
// A REAL disappearance (empty list, not null) still fires the teardown.
result = [];
live = new Set();
await watcher.tickNow();
expect(events).toEqual([
['down', 1234],
['gone', 1234],
]);
watcher.stop();
});
});
@@ -0,0 +1,120 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createProcessEnumerator } from '../src/process-enumerator';
import type { HookProcessBaseInfo } from '../src/injector';
const QQ = (pid: number): HookProcessBaseInfo => ({ pid, name: 'qq', path: '' });
function deferred() {
let resolve!: (processes: HookProcessBaseInfo[]) => void;
let reject!: (error: Error) => void;
const promise = new Promise<HookProcessBaseInfo[]>((done, fail) => {
resolve = done;
reject = fail;
});
return { promise, resolve, reject };
}
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
describe('process enumeration', () => {
it('shares one native request between concurrent callers', async () => {
const request = deferred();
const listProcesses = vi.fn(() => request.promise);
const enumerator = createProcessEnumerator({ listProcesses });
const first = enumerator.enumerate();
expect(enumerator.enumerate()).toBe(first);
request.resolve([QQ(1), QQ(2)]);
expect(await first).toEqual([QQ(1), QQ(2)]);
expect(listProcesses).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
enumerator.dispose();
});
it('keeps timed-out native work in flight until it actually finishes', async () => {
const request = deferred();
const listProcesses = vi.fn(() => request.promise).mockResolvedValueOnce([QQ(1)]);
const enumerator = createProcessEnumerator({ listProcesses, timeoutMs: 100 });
expect(await enumerator.enumerate()).toEqual([QQ(1)]);
const stalled = enumerator.enumerate();
await vi.advanceTimersByTimeAsync(100);
expect(await stalled).toBeNull();
for (let i = 0; i < 10; i++) {
expect(await enumerator.enumerate()).toBeNull();
}
expect(listProcesses).toHaveBeenCalledTimes(2);
request.resolve([QQ(2)]);
await vi.advanceTimersByTimeAsync(0);
listProcesses.mockResolvedValueOnce([QQ(3)]);
expect(await enumerator.enumerate()).toEqual([QQ(3)]);
expect(await stalled).toBeNull();
enumerator.dispose();
});
it.each(['throw', 'reject'] as const)(
'keeps a %s unknown and allows a fresh request',
async (kind) => {
const listProcesses = vi.fn<() => Promise<HookProcessBaseInfo[]>>(() => {
if (kind === 'throw') {
throw new Error('process table unavailable');
}
return Promise.reject(new Error('process table unavailable'));
});
const enumerator = createProcessEnumerator({ listProcesses });
expect(await enumerator.enumerate()).toBeNull();
expect(vi.getTimerCount()).toBe(0);
listProcesses.mockResolvedValueOnce([]);
expect(await enumerator.enumerate()).toEqual([]);
enumerator.dispose();
},
);
it('handles a rejection after timeout and recovers on the next poll', async () => {
const request = deferred();
const listProcesses = vi.fn(() => request.promise);
const enumerator = createProcessEnumerator({ listProcesses, timeoutMs: 100 });
const pending = enumerator.enumerate();
await vi.advanceTimersByTimeAsync(100);
expect(await pending).toBeNull();
request.reject(new Error('late failure'));
await vi.advanceTimersByTimeAsync(0);
listProcesses.mockResolvedValueOnce([QQ(4)]);
expect(await enumerator.enumerate()).toEqual([QQ(4)]);
enumerator.dispose();
});
it('settles disposal immediately without accepting late native results', async () => {
const request = deferred();
const listProcesses = vi.fn(() => request.promise);
const enumerator = createProcessEnumerator({ listProcesses });
const pending = enumerator.enumerate();
await Promise.resolve();
enumerator.dispose();
enumerator.dispose();
expect(await pending).toBeNull();
expect(vi.getTimerCount()).toBe(0);
request.resolve([QQ(1)]);
expect(await enumerator.enumerate()).toBeNull();
expect(listProcesses).toHaveBeenCalledTimes(1);
});
it('never calls the lister after disposal', async () => {
const listProcesses = vi.fn(async () => [QQ(1)]);
const enumerator = createProcessEnumerator({ listProcesses });
enumerator.dispose();
expect(await enumerator.enumerate()).toBeNull();
expect(listProcesses).not.toHaveBeenCalled();
});
it('does not start queued native work when disposed in the same tick', async () => {
const listProcesses = vi.fn(async () => [QQ(1)]);
const enumerator = createProcessEnumerator({ listProcesses });
const pending = enumerator.enumerate();
enumerator.dispose();
expect(await pending).toBeNull();
expect(listProcesses).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,142 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { mkdtemp, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { SqliteDatabase } from '@snowluma/sqlite';
import { isQqProcessName, getDatabaseHolders } from '../src/qq-account/database-holders';
const native = vi.hoisted(() => ({ addon: vi.fn<() => unknown>(() => null) }));
vi.mock('../src/injector', () => ({ getNativeHookAddon: native.addon }));
const IS_LINUX = process.platform === 'linux';
const ORIGINAL_PLATFORM = process.platform;
/** Run `getDatabaseHolders` as if the host were Windows, with a stubbed addon. */
async function asWindows<T>(addon: unknown, run: () => Promise<T>): Promise<T> {
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
native.addon.mockReturnValue(addon);
try {
return await run();
} finally {
Object.defineProperty(process, 'platform', {
value: ORIGINAL_PLATFORM,
configurable: true,
});
}
}
afterEach(() => {
native.addon.mockReset();
native.addon.mockReturnValue(null);
});
describe('database holder probe', () => {
it.runIf(IS_LINUX)('reports nothing for a file that is not there', async () => {
const missing = path.join(os.tmpdir(), `snowluma-absent-${process.pid}.db`);
expect(await getDatabaseHolders(missing)).toEqual([]);
});
it.runIf(IS_LINUX)('finds the process holding a database', async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'snowluma-lock-'));
const file = path.join(directory, 'nt_msg.db');
const db = new SqliteDatabase(file);
try {
// A rollback-journal write transaction takes a POSIX lock on the
// database file itself, which is exactly what a signed-in client
// holds on the account's nt_msg.db.
await db.exec('PRAGMA journal_mode = DELETE');
await db.exec('CREATE TABLE t(value TEXT)');
await db.exec('BEGIN IMMEDIATE');
const holders = await getDatabaseHolders(file);
const self = holders.find((holder) => holder.pid === process.pid);
expect(self).toBeDefined();
// The holder is named from /proc/<pid>/comm, and that name is what
// keeps a holder that is not QQ from being mistaken for one.
expect(self!.name.length).toBeGreaterThan(0);
expect(isQqProcessName(self!.name)).toBe(false);
await db.exec('ROLLBACK');
expect(
(await getDatabaseHolders(file)).some((holder) => holder.pid === process.pid),
).toBe(false);
} finally {
await db.close();
await rm(directory, { recursive: true, force: true });
}
});
});
describe('Windows holder probe', () => {
it('propagates rejected native queries instead of treating them as no login', async () => {
await expect(
asWindows(
{
getFileHoldersAsync: async () => {
throw new Error('RmGetList failed: 5');
},
},
() => getDatabaseHolders('C:\\nt_msg.db'),
),
).rejects.toThrow('RmGetList failed: 5');
});
it('reports the holders the addon lists', async () => {
const holders = await asWindows(
{
getFileHoldersAsync: async () => [
{ pid: 4242, name: 'QQ.exe' },
{ pid: 7, name: 'weq' },
],
},
() => getDatabaseHolders('C:\\Tencent Files\\10001\\nt_qq\\nt_db\\nt_msg.db'),
);
expect(holders).toEqual([
{ pid: 4242, name: 'QQ.exe' },
{ pid: 7, name: 'weq' },
]);
});
it.each([[{ pid: 0, name: 'QQ' }], [{ pid: 9 }], [null], 'not-an-array'])(
'reports malformed native results: %j',
async (result) => {
await expect(
asWindows({ getFileHoldersAsync: async () => result }, () =>
getDatabaseHolders('C:\\nt_msg.db'),
),
).rejects.toThrow('Invalid database holders');
},
);
it('accepts an empty holder list', async () => {
expect(
await asWindows({ getFileHoldersAsync: async () => [] }, () =>
getDatabaseHolders('C:\\nt_msg.db'),
),
).toEqual([]);
});
it.each([null, {}])('reports an unavailable native probe: %j', async (addon) => {
await expect(asWindows(addon, () => getDatabaseHolders('C:\\nt_msg.db'))).rejects.toThrow(
'requires a hook addon with getFileHoldersAsync',
);
});
});
describe('QQ process names', () => {
it('accepts the name each platform reports for the client', () => {
expect(isQqProcessName('QQ')).toBe(true);
expect(isQqProcessName('QQ.exe')).toBe(true);
expect(isQqProcessName(' qq ')).toBe(true);
});
it('rejects other holders', () => {
expect(isQqProcessName('node')).toBe(false);
expect(isQqProcessName('qqmusic')).toBe(false);
expect(isQqProcessName('')).toBe(false);
});
});
@@ -0,0 +1,241 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { spawn } from 'node:child_process';
import { mkdtemp, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { getLogLevel, setLogLevel, subscribeLogs, type LogEntry } from '@snowluma/common/logger';
const mocks = vi.hoisted(() => ({
readLocalAccounts: vi.fn(),
getDatabaseHolders: vi.fn(),
}));
// The account list and the holder probe are covered on their own; this file is
// about how the two are joined.
vi.mock('../src/qq-account/login-db', () => ({ readLocalAccounts: mocks.readLocalAccounts }));
vi.mock('../src/qq-account/database-holders', async (importOriginal) => ({
...(await importOriginal<typeof import('../src/qq-account/database-holders')>()),
getDatabaseHolders: mocks.getDatabaseHolders,
}));
import { detectAccountLogins, detectAccountLoginsForPids } from '../src/qq-account/detect';
import { loginDbPaths, ntMsgDbPath } from '../src/qq-account/paths';
const ALICE = { uin: '10001', uid: 'u_alice', nickname: 'Alice' };
const BOB = { uin: '10002', uid: 'u_bob', nickname: 'Bob' };
const roots: string[] = [];
const children: Array<ReturnType<typeof spawn>> = [];
afterEach(async () => {
mocks.readLocalAccounts.mockReset();
mocks.getDatabaseHolders.mockReset();
for (const child of children.splice(0)) {
child.kill();
}
for (const root of roots.splice(0)) {
await rm(root, { recursive: true, force: true });
}
});
async function makeRoot(): Promise<string> {
const root = await mkdtemp(path.join(os.tmpdir(), 'snowluma-detect-'));
roots.push(root);
return root;
}
/**
* A live process whose `/proc/<pid>/environ` carries `HOME` (and any extra
* variables given).
*
* `XDG_CONFIG_HOME` is dropped unless the caller sets it: it outranks `HOME`
* when deriving the data root, and a CI runner exports its own, which would
* otherwise decide the result instead of the HOME under test.
*
* Resolves only once the child has run past `execve`, because `/proc/<pid>`
* still shows the parent's environment before that.
*/
function spawnWithHome(home: string, extra: Record<string, string> = {}): Promise<number> {
return new Promise((resolve, reject) => {
const env: NodeJS.ProcessEnv = { ...process.env };
delete env.XDG_CONFIG_HOME;
Object.assign(env, { HOME: home }, extra);
const child = spawn(
process.execPath,
['-e', 'console.log("ready"); setTimeout(() => {}, 30000)'],
{ env, stdio: ['ignore', 'pipe', 'ignore'] },
);
children.push(child);
child.stdout!.once('data', () => resolve(child.pid!));
child.once('error', reject);
});
}
describe('local account detection', () => {
it('labels each account with the process holding its database', async () => {
const root = await makeRoot();
mocks.readLocalAccounts.mockResolvedValue({ accounts: [ALICE, BOB], failures: [] });
mocks.getDatabaseHolders.mockImplementation(async (dbPath: string) => {
if (dbPath === ntMsgDbPath(root, ALICE)) {
return [{ pid: 4242, name: 'qq' }];
}
// A holder that is not the client must not be mistaken for one.
return [{ pid: 999, name: 'node' }];
});
await expect(detectAccountLogins([root])).resolves.toEqual([
{ ...ALICE, pid: 4242 },
{ ...BOB, pid: null },
]);
});
it('merges the accounts of every root instead of stopping at the first', async () => {
const own = await makeRoot();
const extra = await makeRoot();
mocks.readLocalAccounts.mockImplementation(async (root: string) =>
root === own ? { accounts: [ALICE], failures: [] } : { accounts: [BOB], failures: [] },
);
mocks.getDatabaseHolders.mockImplementation(async (dbPath: string) =>
dbPath === ntMsgDbPath(extra, BOB) ? [{ pid: 5151, name: 'qq' }] : [],
);
await expect(detectAccountLogins([own, extra])).resolves.toEqual([
{ ...ALICE, pid: null },
{ ...BOB, pid: 5151 },
]);
// An account held under an instance's own HOME is still labelled, which
// is what a single-root lookup cannot do.
expect(mocks.getDatabaseHolders).toHaveBeenCalledWith(ntMsgDbPath(extra, BOB));
});
it('keeps account records with the same uin scoped to their own root', async () => {
const first = await makeRoot();
const second = await makeRoot();
mocks.readLocalAccounts.mockImplementation(async (root: string) =>
root === first
? { accounts: [ALICE], failures: [] }
: { accounts: [{ ...ALICE, nickname: 'Stale' }], failures: [] },
);
mocks.getDatabaseHolders.mockImplementation(async (file: string) =>
file === ntMsgDbPath(second, ALICE) ? [{ pid: 5151, name: 'qq' }] : [],
);
await expect(detectAccountLogins([first, second])).resolves.toEqual([
{ ...ALICE, pid: null },
{ ...ALICE, nickname: 'Stale', pid: 5151 },
]);
});
it('reports nothing when there are no roots', async () => {
await expect(detectAccountLogins([])).resolves.toEqual([]);
expect(mocks.readLocalAccounts).not.toHaveBeenCalled();
});
it('logs a named reason when a login.db is present but unreadable', async () => {
const root = await makeRoot();
const entries: LogEntry[] = [];
const previousLevel = getLogLevel();
setLogLevel('debug');
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
try {
mocks.readLocalAccounts.mockResolvedValue({
accounts: [],
failures: [
{
path: loginDbPaths(root)[0]!,
reason: 'undecryptable',
},
],
});
mocks.getDatabaseHolders.mockResolvedValue([]);
// An unreadable login.db must not look like "no accounts": an
// operator reading the log has to see why nothing was reported.
await detectAccountLogins([root]);
} finally {
unsubscribe();
setLogLevel(previousLevel);
}
const warned = entries.filter(
(entry) => entry.level === 'warn' && entry.scope === 'QqAccount',
);
expect(warned).toHaveLength(1);
expect(warned[0]!.message).toContain('login.db unreadable');
expect(warned[0]!.message).toContain('undecryptable');
});
});
describe.runIf(process.platform === 'linux')('per-process data roots', () => {
it('reads each pid against the HOME that process runs under', async () => {
const home = await makeRoot();
const pid = await spawnWithHome(home);
mocks.readLocalAccounts.mockResolvedValue({ accounts: [], failures: [] });
mocks.getDatabaseHolders.mockResolvedValue([]);
await detectAccountLoginsForPids([pid]);
// `<HOME>/.config/QQ`, from the child's environment rather than ours.
expect(mocks.readLocalAccounts).toHaveBeenCalledWith(path.join(home, '.config', 'QQ'));
});
it('asks each root once when several processes share a HOME', async () => {
const home = await makeRoot();
const pids = [await spawnWithHome(home), await spawnWithHome(home)];
mocks.readLocalAccounts.mockResolvedValue({ accounts: [], failures: [] });
mocks.getDatabaseHolders.mockResolvedValue([]);
await detectAccountLoginsForPids(pids);
expect(mocks.readLocalAccounts).toHaveBeenCalledTimes(1);
});
it('honours XDG_CONFIG_HOME of the process over its HOME', async () => {
const home = await makeRoot();
const configHome = await makeRoot();
const pid = await spawnWithHome(home, { XDG_CONFIG_HOME: configHome });
mocks.readLocalAccounts.mockResolvedValue({ accounts: [], failures: [] });
mocks.getDatabaseHolders.mockResolvedValue([]);
await detectAccountLoginsForPids([pid]);
// The XDG variable outranks HOME, as it does for the client itself.
expect(mocks.readLocalAccounts).toHaveBeenCalledWith(path.join(configHome, 'QQ'));
});
it('keeps each process on its own root when the HOMEs differ', async () => {
const first = await makeRoot();
const second = await makeRoot();
const pids = [await spawnWithHome(first), await spawnWithHome(second)];
mocks.readLocalAccounts.mockResolvedValue({ accounts: [], failures: [] });
mocks.getDatabaseHolders.mockResolvedValue([]);
await detectAccountLoginsForPids(pids);
expect(mocks.readLocalAccounts).toHaveBeenCalledWith(path.join(first, '.config', 'QQ'));
expect(mocks.readLocalAccounts).toHaveBeenCalledWith(path.join(second, '.config', 'QQ'));
});
it('does not substitute another HOME when the pid is gone', async () => {
const home = await makeRoot();
const pid = await spawnWithHome(home);
mocks.readLocalAccounts.mockResolvedValue({ accounts: [], failures: [] });
mocks.getDatabaseHolders.mockResolvedValue([]);
// The child exits, so its environment is no longer readable.
await new Promise<void>((resolve) => {
const child = children[children.length - 1]!;
child.once('exit', () => resolve());
child.kill();
});
await detectAccountLoginsForPids([pid]);
expect(mocks.readLocalAccounts).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,195 @@
/**
* Encryption helpers for the account-detection tests.
*
* The shipped code only decrypts, so producing an encrypted fixture means
* mirroring SQLCipher's page layout here: `[client header][page 1]…` with each
* page ending in `IV || page HMAC`, and a `-wal` whose frame header stays
* plaintext while the page inside it is encrypted.
*/
import { createCipheriv, createHmac, pbkdf2Sync, randomBytes } from 'node:crypto';
import type { KdfHmacAlgorithm, PageHmacAlgorithm } from '../src/qq-account/sqlcipher';
export interface FixtureAlgorithm {
pageHmac: PageHmacAlgorithm;
kdfHmac: KdfHmacAlgorithm;
}
export const FIXTURE_PASSPHRASE = 'BD156D6710D54D8782F4';
export const FIXTURE_PAGE_SIZE = 4096;
export const FIXTURE_HEADER_SIZE = 1024;
export const FIXTURE_WAL_HEADER_SIZE = 32;
export const FIXTURE_WAL_FRAME_HEADER_SIZE = 24;
const DIGEST_BYTES: Record<PageHmacAlgorithm, number> = {
none: 0,
sha1: 20,
sha256: 32,
sha512: 64,
};
const KDF_ITERATIONS = 4000;
const HMAC_KEY_ITERATIONS = 2;
const HMAC_SALT_MASK = 0x3a;
export function reserveBytes(algorithm: FixtureAlgorithm): number {
return Math.ceil((16 + DIGEST_BYTES[algorithm.pageHmac]) / 16) * 16;
}
/** Encrypt a plaintext SQLite image the way QQ NT stores it. */
export function encryptImage(
plain: Buffer,
algorithm: FixtureAlgorithm,
passphrase = FIXTURE_PASSPHRASE,
salt = randomBytes(16),
): Buffer {
const pageCount = plain.length / FIXTURE_PAGE_SIZE;
const key = pbkdf2Sync(passphrase, salt, KDF_ITERATIONS, 32, algorithm.kdfHmac);
const output = Buffer.alloc(FIXTURE_HEADER_SIZE + plain.length);
for (let index = 0; index < pageCount; index++) {
const pageNumber = index + 1;
const skip = pageNumber === 1 ? 16 : 0;
const body = plain.subarray(
index * FIXTURE_PAGE_SIZE + skip,
(index + 1) * FIXTURE_PAGE_SIZE,
);
if (pageNumber === 1) {
// Page 1 stores the KDF salt where SQLite keeps its magic.
salt.copy(output, FIXTURE_HEADER_SIZE);
}
encryptPageInto(
output,
FIXTURE_HEADER_SIZE + index * FIXTURE_PAGE_SIZE,
body,
pageNumber,
key,
salt,
algorithm,
);
}
return output;
}
/** Encrypt the pages of a real (plaintext) WAL, recomputing the frame checksums. */
export function encryptWal(
plainWal: Buffer,
encryptedImage: Buffer,
algorithm: FixtureAlgorithm,
passphrase = FIXTURE_PASSPHRASE,
): Buffer {
const salt = encryptedImage.subarray(FIXTURE_HEADER_SIZE, FIXTURE_HEADER_SIZE + 16);
const key = pbkdf2Sync(passphrase, salt, KDF_ITERATIONS, 32, algorithm.kdfHmac);
const frameSize = FIXTURE_WAL_FRAME_HEADER_SIZE + FIXTURE_PAGE_SIZE;
const output = Buffer.alloc(plainWal.length);
plainWal.copy(output, 0, 0, FIXTURE_WAL_HEADER_SIZE);
let running = walChecksum([0, 0], [output.subarray(0, FIXTURE_WAL_HEADER_SIZE - 8)], true);
for (
let offset = FIXTURE_WAL_HEADER_SIZE;
offset + frameSize <= plainWal.length;
offset += frameSize
) {
const frameHeader = plainWal.subarray(offset, offset + FIXTURE_WAL_FRAME_HEADER_SIZE);
const pageNumber = frameHeader.readUInt32BE(0);
const skip = pageNumber === 1 ? 16 : 0;
const page = plainWal.subarray(offset + FIXTURE_WAL_FRAME_HEADER_SIZE, offset + frameSize);
encryptPageInto(
output,
offset + FIXTURE_WAL_FRAME_HEADER_SIZE,
page.subarray(skip),
pageNumber,
key,
salt,
algorithm,
);
const stored = output.subarray(offset, offset + FIXTURE_WAL_FRAME_HEADER_SIZE);
frameHeader.copy(stored, 0, 0, 8);
running = walChecksum(
running,
[
stored.subarray(0, 8),
output.subarray(offset + FIXTURE_WAL_FRAME_HEADER_SIZE, offset + frameSize),
],
true,
);
stored.writeUInt32BE(running[0], 16);
stored.writeUInt32BE(running[1], 20);
}
return output;
}
/**
* SQLite's rolling WAL checksum, as the fixture needs it to rebuild frame
* checksums — and to prove the shipped implementation agrees with what SQLite
* itself wrote in a real `-wal`.
*/
export function walChecksum(
seed: [number, number],
chunks: readonly Buffer[],
littleEndian: boolean,
): [number, number] {
let first = seed[0];
let second = seed[1];
for (const chunk of chunks) {
for (let offset = 0; offset + 8 <= chunk.length; offset += 8) {
const high = littleEndian ? chunk.readUInt32LE(offset) : chunk.readUInt32BE(offset);
const low = littleEndian
? chunk.readUInt32LE(offset + 4)
: chunk.readUInt32BE(offset + 4);
first = (first + high + second) >>> 0;
second = (second + low + first) >>> 0;
}
}
return [first, second];
}
function encryptPageInto(
output: Buffer,
pageOffset: number,
body: Buffer,
pageNumber: number,
key: Buffer,
salt: Buffer,
algorithm: FixtureAlgorithm,
): void {
// Page 1 stores the KDF salt where SQLite keeps its magic, so its ciphertext
// starts one salt past the page boundary.
const offset = pageOffset + (pageNumber === 1 ? 16 : 0);
const reserve = reserveBytes(algorithm);
const cipherLength = body.length - reserve;
const iv = randomBytes(16);
const cipher = createCipheriv('aes-256-cbc', key, iv);
cipher.setAutoPadding(false);
const ciphertext = Buffer.concat([
cipher.update(body.subarray(0, cipherLength)),
cipher.final(),
]);
ciphertext.copy(output, offset);
iv.copy(output, offset + cipherLength);
const digestBytes = DIGEST_BYTES[algorithm.pageHmac];
if (digestBytes === 0) {
return;
}
const hmacSalt = Buffer.from(salt.map((byte) => byte ^ HMAC_SALT_MASK));
const hmacKey = pbkdf2Sync(key, hmacSalt, HMAC_KEY_ITERATIONS, 32, algorithm.kdfHmac);
const pageNumberBytes = Buffer.alloc(4);
pageNumberBytes.writeUInt32LE(pageNumber);
const digest = createHmac(algorithm.pageHmac, hmacKey)
.update(ciphertext)
.update(iv)
.update(pageNumberBytes)
.digest();
digest.copy(output, offset + cipherLength + 16);
}
@@ -0,0 +1,246 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { SqliteDatabase } from '@snowluma/sqlite';
const mocks = vi.hoisted(() => ({
detectAlgorithm: vi.fn(),
decryptDatabase: vi.fn(),
}));
// The page crypto is covered on its own; account reading stands on top of it, so
// the fixtures below hand over plaintext images and the decrypt step is stubbed
// to pass them through.
vi.mock('../src/qq-account/sqlcipher', () => ({
detectAlgorithm: mocks.detectAlgorithm,
decryptDatabase: mocks.decryptDatabase,
}));
vi.mock('../src/qq-account/paths', () => ({
loginDbPaths: (root: string) => [path.join(root, PRIMARY), path.join(root, SECONDARY)],
}));
import { readLocalAccounts } from '../src/qq-account/login-db';
const PRIMARY = path.join('global', 'nt_db', 'login.db');
const SECONDARY = path.join('nt_qq', 'global', 'nt_db', 'login.db');
/** Write a plaintext database with QQ's account table at `file`. */
async function writeLoginDb(
file: string,
rows: ReadonlyArray<readonly [string, string, string]>,
): Promise<void> {
await mkdir(path.dirname(file), { recursive: true });
await rm(file, { force: true });
await rm(`${file}-wal`, { force: true });
const db = new SqliteDatabase(file);
try {
await db.exec('CREATE TABLE login_table("1000" TEXT, "1001" TEXT, "1007" TEXT)');
for (const [uin, uid, nickname] of rows) {
await db.prepare('INSERT INTO login_table VALUES (?, ?, ?)').run(uin, uid, nickname);
}
} finally {
await db.close();
}
}
async function makeRoot(
rows: ReadonlyArray<readonly [string, string, string]>,
secondaryRows: ReadonlyArray<readonly [string, string, string]> = [],
): Promise<string> {
const root = await mkdtemp(path.join(os.tmpdir(), 'snowluma-login-'));
await writeLoginDb(path.join(root, PRIMARY), rows);
if (secondaryRows.length > 0) {
await writeLoginDb(path.join(root, SECONDARY), secondaryRows);
}
return root;
}
const roots: string[] = [];
beforeEach(() => {
mocks.detectAlgorithm.mockReturnValue({ pageHmac: 'sha1', kdfHmac: 'sha512' });
mocks.decryptDatabase.mockImplementation((image: Buffer) => ({ image, walFrames: 0 }));
});
afterEach(async () => {
mocks.detectAlgorithm.mockReset();
mocks.decryptDatabase.mockReset();
for (const root of roots.splice(0)) {
await rm(root, { recursive: true, force: true });
}
});
async function trackedRoot(
rows: ReadonlyArray<readonly [string, string, string]>,
secondaryRows: ReadonlyArray<readonly [string, string, string]> = [],
): Promise<string> {
const root = await makeRoot(rows, secondaryRows);
roots.push(root);
return root;
}
describe('local account list', () => {
it('reads the accounts QQ cached locally', async () => {
const root = await trackedRoot([['10001', 'u_a', 'Alice']]);
await expect(readLocalAccounts(root)).resolves.toEqual({
accounts: [{ uin: '10001', uid: 'u_a', nickname: 'Alice' }],
failures: [],
});
});
it('merges both login.db locations, letting the primary win on a clash', async () => {
const root = await trackedRoot(
[
['10001', 'u_a', 'Alice'],
['10002', 'u_b', 'Bob'],
],
[
['10001', 'u_a_stale', 'AliceStale'],
['10003', 'u_c', 'Carol'],
],
);
expect(await readLocalAccounts(root)).toEqual({
accounts: [
{ uin: '10001', uid: 'u_a', nickname: 'Alice' },
{ uin: '10002', uid: 'u_b', nickname: 'Bob' },
{ uin: '10003', uid: 'u_c', nickname: 'Carol' },
],
failures: [],
});
});
it('re-decrypts only when the database or its sidecar changes', async () => {
const root = await trackedRoot([['10001', 'u_a', 'Alice']]);
const file = path.join(root, PRIMARY);
await readLocalAccounts(root);
await readLocalAccounts(root);
expect(mocks.decryptDatabase).toHaveBeenCalledTimes(1);
// A login that just happened rewrites the database; the new rows have to
// show up instead of the cached list.
await writeLoginDb(file, [
['10001', 'u_a', 'Alice'],
['10002', 'u_b', 'Bob'],
]);
await utimes(file, new Date(Date.now() + 60_000), new Date(Date.now() + 60_000));
expect((await readLocalAccounts(root)).accounts).toHaveLength(2);
expect(mocks.decryptDatabase).toHaveBeenCalledTimes(2);
// The sidecar counts as a change too: a batch committed to the WAL only
// leaves login.db untouched.
await writeFile(`${file}-wal`, Buffer.alloc(0));
await readLocalAccounts(root);
expect(mocks.decryptDatabase).toHaveBeenCalledTimes(3);
});
it('shares concurrent reads of the same database image', async () => {
const root = await trackedRoot([['10001', 'u_a', 'Alice']]);
const reports = await Promise.all([readLocalAccounts(root), readLocalAccounts(root)]);
expect(reports[0]).toEqual(reports[1]);
expect(mocks.decryptDatabase).toHaveBeenCalledTimes(1);
});
it('reports an unreadable WAL instead of reading a stale base image', async () => {
const root = await trackedRoot([['10001', 'u_a', 'Alice']]);
const file = path.join(root, PRIMARY);
await mkdir(`${file}-wal`);
const report = await readLocalAccounts(root);
expect(report.accounts).toEqual([]);
expect(report.failures).toMatchObject([{ path: file, reason: 'read-failed' }]);
expect(report.failures[0]!.error).toBeDefined();
});
it('reports missing schema columns instead of accepting quoted column names as identities', async () => {
const root = await trackedRoot([['10001', 'u_a', 'Alice']]);
const file = path.join(root, PRIMARY);
const db = new SqliteDatabase(file);
try {
await db.exec('ALTER TABLE login_table DROP COLUMN "1001"');
} finally {
await db.close();
}
const report = await readLocalAccounts(root);
expect(report.accounts).toEqual([]);
expect(report.failures).toMatchObject([{ path: file, reason: 'query-failed' }]);
expect(report.failures[0]!.error).toBeDefined();
});
it('reports no accounts when the file cannot be decrypted, and retries later', async () => {
const root = await trackedRoot([['10001', 'u_a', 'Alice']]);
mocks.detectAlgorithm.mockReturnValue(null);
await expect(readLocalAccounts(root)).resolves.toEqual({
accounts: [],
failures: [{ path: path.join(root, PRIMARY), reason: 'undecryptable' }],
});
mocks.detectAlgorithm.mockReturnValue({ pageHmac: 'sha1', kdfHmac: 'sha512' });
const file = path.join(root, PRIMARY);
await utimes(file, new Date(Date.now() + 60_000), new Date(Date.now() + 60_000));
await expect(readLocalAccounts(root)).resolves.toEqual({
accounts: [{ uin: '10001', uid: 'u_a', nickname: 'Alice' }],
failures: [],
});
});
it('names an unreadable login.db instead of reporting "no accounts"', async () => {
const root = await trackedRoot([['10001', 'u_a', 'Alice']]);
mocks.detectAlgorithm.mockReturnValue(null);
// A login.db that cannot be decrypted is not the same as a machine with
// no accounts, and the caller has to be able to tell them apart.
await expect(readLocalAccounts(root)).resolves.toEqual({
accounts: [],
failures: [{ path: path.join(root, PRIMARY), reason: 'undecryptable' }],
});
});
it('keeps an unreadable sidecar from hiding the accounts a good database has', async () => {
const root = await trackedRoot([['10001', 'u_a', 'Alice']], [['10002', 'u_b', 'Bob']]);
const secondary = path.join(root, SECONDARY);
const bogus = Buffer.alloc(8192, 7);
mocks.detectAlgorithm.mockImplementation((image: Buffer) =>
image[0] === 7 ? null : { pageHmac: 'sha1', kdfHmac: 'sha512' },
);
await writeFile(secondary, bogus);
const report = await readLocalAccounts(root);
expect(report.accounts.map((account) => account.uin)).toEqual(['10001']);
expect(report.failures).toEqual([{ path: secondary, reason: 'undecryptable' }]);
});
it('skips rows that carry no usable identity', async () => {
const root = await trackedRoot([
['10001', 'u_a', 'Alice'],
['10002', '', 'Broken'],
['', 'u_d', 'Nameless'],
['../10001', 'u_e', 'Invalid UIN'],
]);
await expect(readLocalAccounts(root)).resolves.toEqual({
accounts: [{ uin: '10001', uid: 'u_a', nickname: 'Alice' }],
failures: [],
});
});
it('reports no accounts when login.db is missing', async () => {
const root = await mkdtemp(path.join(os.tmpdir(), 'snowluma-login-'));
roots.push(root);
// Neither candidate file exists: nothing cached, and nothing to report.
await expect(readLocalAccounts(root)).resolves.toEqual({ accounts: [], failures: [] });
});
});
@@ -0,0 +1,51 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import path from 'node:path';
import { qqDataRoots, qqDataRootsForPid } from '../src/qq-account/paths';
const files = vi.hoisted(() => ({ read: vi.fn() }));
vi.mock('node:fs', () => ({ readFileSync: files.read }));
const platform = process.platform;
beforeEach(() => {
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
});
afterEach(() => {
Object.defineProperty(process, 'platform', { value: platform, configurable: true });
files.read.mockReset();
});
describe('account data root isolation', () => {
it('uses the client HOME and honours its XDG configuration', () => {
files.read.mockReturnValue('HOME=/clients/a\0IGNORED=value\0');
expect(qqDataRootsForPid(42)).toEqual([path.join('/clients/a', '.config', 'QQ')]);
files.read.mockReturnValue('HOME=/clients/a\0XDG_CONFIG_HOME=/clients/config\0');
expect(qqDataRootsForPid(42)).toEqual([path.join('/clients/config', 'QQ')]);
});
it('does not guess a HOME when the client environment has none', () => {
files.read.mockReturnValue('OTHER=value\0');
expect(qqDataRootsForPid(42)).toEqual([]);
});
it('reports no root for an exited process', () => {
files.read.mockImplementation(() => {
throw Object.assign(new Error('gone'), { code: 'ENOENT' });
});
expect(qqDataRootsForPid(42)).toEqual([]);
});
it('surfaces permission failures instead of reading another user directory', () => {
const error = Object.assign(new Error('denied'), { code: 'EACCES' });
files.read.mockImplementation(() => {
throw error;
});
expect(() => qqDataRootsForPid(42)).toThrow(error);
});
it('uses only the configured Windows data root', () => {
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
files.read.mockReturnValue('[UserDataSet]\nUserDataSavePath=D:\\QQ\\Tencent Files\n');
expect(qqDataRoots()).toEqual(['D:\\QQ\\Tencent Files']);
expect(files.read).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,106 @@
import { describe, expect, it } from 'vitest';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
accountDirName,
loginDbPaths,
ntMsgDbPath,
tencentFilesRootFromUserDataInfo,
} from '../src/qq-account/paths';
const IS_WINDOWS = process.platform === 'win32';
describe('QQ data paths', () => {
it('derives the Linux account directory from the account uid', () => {
// md5(md5("u_test_account") + "nt_kernel")
expect(accountDirName('u_test_account')).toBe('nt_qq_7b9b3c3f2cf89e087b2e024777ea5f5f');
});
it('lists the login.db locations of the current platform', () => {
const root = 'root';
const windows = path.join(root, 'nt_qq', 'global', 'nt_db', 'login.db');
expect(loginDbPaths(root)).toEqual(
IS_WINDOWS ? [windows] : [path.join(root, 'global', 'nt_db', 'login.db'), windows],
);
});
it('locates an account database per platform', () => {
const root = 'root';
const account = { uin: '10001', uid: 'u_test_account' };
expect(ntMsgDbPath(root, account)).toBe(
IS_WINDOWS
? path.join(root, '10001', 'nt_qq', 'nt_db', 'nt_msg.db')
: path.join(root, 'nt_qq_7b9b3c3f2cf89e087b2e024777ea5f5f', 'nt_db', 'nt_msg.db'),
);
});
});
describe('Windows data root detection', () => {
/** Recreate `<Public>/Documents/Tencent/QQ/UserDataInfo.ini` for a fake home. */
async function withUserDataInfo(
contents: string,
run: (home: string) => Promise<void> | void,
): Promise<void> {
const base = await mkdtemp(path.join(os.tmpdir(), 'snowluma-ini-'));
try {
const home = path.join(base, 'user');
const ini = path.join(base, 'Public', 'Documents', 'Tencent', 'QQ', 'UserDataInfo.ini');
await mkdir(path.dirname(ini), { recursive: true });
await writeFile(ini, contents, 'utf8');
await run(home);
} finally {
await rm(base, { recursive: true, force: true });
}
}
it('reads UserDataSavePath from the ini QQ itself writes', async () => {
await withUserDataInfo(
'[UserDataSet]\nUserDataSavePath=D:\\Tencent\\Tencent Files\n',
(home) => {
expect(tencentFilesRootFromUserDataInfo(home)).toBe('D:\\Tencent\\Tencent Files');
},
);
});
it('appends the Tencent Files segment when the ini stops one level short', async () => {
await withUserDataInfo('[UserDataSet]\nUserDataSavePath=D:\\Tencent\n', (home) => {
expect(tencentFilesRootFromUserDataInfo(home)).toBe('D:\\Tencent\\Tencent Files');
});
});
it('ignores other sections and the UserDataSavePathType key', async () => {
await withUserDataInfo(
[
'[UserDataImportSet]',
'OldVerDataPath=D:\\Legacy\\Tencent Files',
'[UserDataSet]',
'UserDataSavePathType=2',
'UserDataSavePath=E:\\Current\\Tencent Files',
'',
].join('\n'),
(home) => {
expect(tencentFilesRootFromUserDataInfo(home)).toBe('E:\\Current\\Tencent Files');
},
);
});
it('reports nothing when the ini is missing', async () => {
const base = await mkdtemp(path.join(os.tmpdir(), 'snowluma-ini-'));
try {
expect(tencentFilesRootFromUserDataInfo(path.join(base, 'absent'))).toBeNull();
} finally {
await rm(base, { recursive: true, force: true });
}
});
it('reports nothing when the ini has no usable path', async () => {
await withUserDataInfo('[UserDataSet]\nUserDataSavePath=\n', (home) => {
expect(tencentFilesRootFromUserDataInfo(home)).toBeNull();
});
});
});
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { detectAccountLogins } from '../src/qq-account/detect';
/**
* Account detection against a real QQ data directory.
*
* Point `SNOWLUMA_QQ_DATA_ROOT` at one (for example `~/.config/QQ`) to run
* these; without it they are skipped, which is what CI and any machine without
* the client do. They are the only check that exercises login.db as QQ actually
* writes it, together with the holder probe on the databases it currently holds.
*/
const REAL_DATA_ROOT = process.env.SNOWLUMA_QQ_DATA_ROOT;
describe('account detection against real client data', () => {
it.runIf(Boolean(REAL_DATA_ROOT))('reads the accounts and their processes', async () => {
const accounts = await detectAccountLogins([REAL_DATA_ROOT!]);
expect(accounts.length).toBeGreaterThan(0);
for (const account of accounts) {
expect(account.uin).toMatch(/^\d{5,10}$/);
expect(account.uid).toMatch(/^u_/);
if (account.pid !== null) {
expect(Number.isInteger(account.pid)).toBe(true);
expect(() => process.kill(account.pid!, 0)).not.toThrow();
}
}
});
});
@@ -0,0 +1,324 @@
import { describe, expect, it } from 'vitest';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { SqliteDatabase } from '@snowluma/sqlite';
import {
DATABASE_HEADER_SIZE,
decryptDatabase,
detectAlgorithm,
type SqlcipherAlgorithm,
} from '../src/qq-account/sqlcipher';
import {
FIXTURE_HEADER_SIZE,
FIXTURE_PAGE_SIZE,
FIXTURE_PASSPHRASE,
FIXTURE_WAL_FRAME_HEADER_SIZE,
FIXTURE_WAL_HEADER_SIZE,
encryptImage,
encryptWal,
reserveBytes,
walChecksum,
type FixtureAlgorithm,
} from './qq-account-fixtures';
const SHA1_SHA512: FixtureAlgorithm = { pageHmac: 'sha1', kdfHmac: 'sha512' };
/**
* Reserved space SQLite is told about for an encrypted database: room for the
* IV plus the largest page digest. SQLCipher's own reserve always fits inside
* it, which is why the tail of every page is disposable.
*/
const HEADER_RESERVED_BYTES = 80;
/** Offset of one frame in a `-wal` buffer. */
function frameOffset(index: number): number {
return FIXTURE_WAL_HEADER_SIZE + index * (FIXTURE_WAL_FRAME_HEADER_SIZE + FIXTURE_PAGE_SIZE);
}
/** Last frame index a merge may have applied, or -1 when nothing was committed. */
function lastCommitFrame(wal: Buffer, maxFrameIndex = frameCount(wal) - 1): number {
let last = -1;
for (let index = 0; index <= maxFrameIndex; index++) {
if (wal.readUInt32BE(frameOffset(index) + 4) !== 0) {
last = index;
}
}
return last;
}
function frameCount(wal: Buffer): number {
const frameSize = FIXTURE_WAL_FRAME_HEADER_SIZE + FIXTURE_PAGE_SIZE;
return Math.floor((wal.length - FIXTURE_WAL_HEADER_SIZE) / frameSize);
}
/** Frames up to and including the last commit, i.e. what a merge should apply. */
function committedFrames(wal: Buffer, maxFrameIndex?: number): number {
return lastCommitFrame(wal, maxFrameIndex) + 1;
}
/** Database size the last applicable commit declares. */
function lastCommitSize(wal: Buffer, maxFrameIndex?: number): number {
return wal.readUInt32BE(frameOffset(lastCommitFrame(wal, maxFrameIndex)) + 4);
}
/** Page content as the last applicable commit left it, keyed by page number. */
function committedPages(wal: Buffer, maxFrameIndex?: number): Map<number, Buffer> {
const pages = new Map<number, Buffer>();
const last = lastCommitFrame(wal, maxFrameIndex);
const size = lastCommitSize(wal, maxFrameIndex);
for (let index = 0; index <= last; index++) {
const pageNumber = wal.readUInt32BE(frameOffset(index));
if (pageNumber >= 1 && pageNumber <= size) {
pages.set(
pageNumber,
wal.subarray(
frameOffset(index) + FIXTURE_WAL_FRAME_HEADER_SIZE,
frameOffset(index + 1),
),
);
}
}
return pages;
}
/** Compare one merged page against the page a frame carried. */
function expectPageMatches(
merged: Buffer,
expectedPage: Buffer,
pageNumber: number,
algorithm: FixtureAlgorithm,
): void {
const reserve = reserveBytes(algorithm);
const start = (pageNumber - 1) * FIXTURE_PAGE_SIZE;
// Page 1's SQLite header carries the fields the merge keeps consistent with
// the assembled image (change counter, page count, journal mode), so only the
// page body is compared there.
const bodyStart = pageNumber === 1 ? 100 : 0;
expect(merged.subarray(start + bodyStart, start + FIXTURE_PAGE_SIZE - reserve)).toEqual(
expectedPage.subarray(bodyStart, FIXTURE_PAGE_SIZE - reserve),
);
expect(
merged
.subarray(start + FIXTURE_PAGE_SIZE - reserve, start + FIXTURE_PAGE_SIZE)
.equals(Buffer.alloc(reserve)),
).toBe(true);
}
/** A plaintext image with the header fields the merge keeps consistent. */
function plaintextImage(pageCount: number, marker = 0): Buffer {
const image = Buffer.alloc(pageCount * FIXTURE_PAGE_SIZE);
for (let page = 0; page < pageCount; page++) {
for (let offset = 0; offset < FIXTURE_PAGE_SIZE - HEADER_RESERVED_BYTES; offset++) {
image[page * FIXTURE_PAGE_SIZE + offset] = (page * 31 + offset + marker) % 251;
}
}
image.write('SQLite format 3\0', 0, 'latin1');
image.writeUInt16BE(FIXTURE_PAGE_SIZE, 16);
image[18] = 1;
image[19] = 1;
image[20] = HEADER_RESERVED_BYTES;
image[21] = 64;
image[22] = 32;
image[23] = 32;
image.writeUInt32BE(7, 24);
image.writeUInt32BE(pageCount, 28);
image.writeUInt32BE(7, 92);
return image;
}
/** A real SQLite database in WAL mode, plus the sidecar SQLite wrote for it. */
async function realWalDatabase(
directory: string,
inserts: string[][],
): Promise<{ image: Buffer; wal: Buffer; db: SqliteDatabase }> {
const file = path.join(directory, 'source.db');
const db = new SqliteDatabase(file);
await db.exec('PRAGMA wal_autocheckpoint = 0');
await db.exec('CREATE TABLE t(value TEXT)');
for (const [value] of inserts) {
await db.prepare('INSERT INTO t VALUES (?)').run(value);
}
return { image: await readFile(file), wal: await readFile(`${file}-wal`), db };
}
describe('SQLCipher page decryption', () => {
it('round-trips a page image and normalizes the header', () => {
const plain = plaintextImage(3);
plain[18] = 2; // stored as a WAL-mode database, like QQ writes them
plain[19] = 2;
const encrypted = encryptImage(plain, SHA1_SHA512);
expect(detectAlgorithm(encrypted, FIXTURE_PASSPHRASE)).toEqual(SHA1_SHA512);
const decrypted = decryptDatabase(encrypted, null, FIXTURE_PASSPHRASE, SHA1_SHA512);
expect(decrypted).not.toBeNull();
const expected = Buffer.from(plain);
expected[18] = 1;
expected[19] = 1;
expected.writeUInt32BE(3, 28);
expected.writeUInt32BE(7, 92);
expect(decrypted!.image.equals(expected)).toBe(true);
expect(decrypted!.walFrames).toBe(0);
});
it('probes the algorithm pair instead of assuming one', () => {
const plain = plaintextImage(2);
for (const algorithm of [
{ pageHmac: 'sha256', kdfHmac: 'sha256' },
{ pageHmac: 'sha512', kdfHmac: 'sha1' },
{ pageHmac: 'none', kdfHmac: 'sha512' },
] satisfies SqlcipherAlgorithm[]) {
const encrypted = encryptImage(plain, algorithm);
expect(detectAlgorithm(encrypted, FIXTURE_PASSPHRASE)).toEqual(algorithm);
expect(
decryptDatabase(encrypted, null, FIXTURE_PASSPHRASE, algorithm)!.image.equals(
plain,
),
).toBe(true);
}
});
it('rejects a wrong passphrase and a file that is too short', () => {
const encrypted = encryptImage(plaintextImage(2), SHA1_SHA512);
expect(detectAlgorithm(encrypted, 'wrong-passphrase')).toBeNull();
expect(
detectAlgorithm(Buffer.alloc(DATABASE_HEADER_SIZE + 512), FIXTURE_PASSPHRASE),
).toBeNull();
expect(decryptDatabase(Buffer.alloc(64), null, FIXTURE_PASSPHRASE, SHA1_SHA512)).toBeNull();
});
});
describe('SQLCipher WAL folding', () => {
it('reproduces the frame checksums SQLite itself wrote', async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'snowluma-wal-'));
try {
const { wal, db } = await realWalDatabase(directory, [['a'], ['b']]);
const littleEndian = wal.readUInt32BE(0) === 0x377f0682;
const frameSize = FIXTURE_WAL_FRAME_HEADER_SIZE + FIXTURE_PAGE_SIZE;
expect((wal.length - FIXTURE_WAL_HEADER_SIZE) % frameSize).toBe(0);
const header = walChecksum(
[0, 0],
[wal.subarray(0, FIXTURE_WAL_HEADER_SIZE - 8)],
littleEndian,
);
expect(header).toEqual([wal.readUInt32BE(24), wal.readUInt32BE(28)]);
let running = header;
for (
let offset = FIXTURE_WAL_HEADER_SIZE;
offset + frameSize <= wal.length;
offset += frameSize
) {
const frame = wal.subarray(offset, offset + FIXTURE_WAL_FRAME_HEADER_SIZE);
running = walChecksum(
running,
[
frame.subarray(0, 8),
wal.subarray(offset + FIXTURE_WAL_FRAME_HEADER_SIZE, offset + frameSize),
],
littleEndian,
);
expect(running).toEqual([frame.readUInt32BE(16), frame.readUInt32BE(20)]);
}
await db.close();
} finally {
await rm(directory, { recursive: true, force: true });
}
});
it('folds every committed frame into the decrypted image', async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'snowluma-wal-'));
try {
const { image, wal, db } = await realWalDatabase(directory, [['a'], ['b']]);
const encryptedImage = encryptImage(image, SHA1_SHA512);
const encryptedWal = encryptWal(wal, encryptedImage, SHA1_SHA512);
const decrypted = decryptDatabase(
encryptedImage,
encryptedWal,
FIXTURE_PASSPHRASE,
SHA1_SHA512,
);
expect(decrypted!.walFrames).toBe(committedFrames(wal));
expect(decrypted!.image.length).toBe(lastCommitSize(wal) * FIXTURE_PAGE_SIZE);
// Every page a committed frame carried has to arrive as it was written,
// apart from the on-disk IV/HMAC region the encryptor owns.
for (const [pageNumber, page] of committedPages(wal)) {
expectPageMatches(decrypted!.image, page, pageNumber, SHA1_SHA512);
}
await db.close();
} finally {
await rm(directory, { recursive: true, force: true });
}
});
it('keeps the intact prefix of a WAL torn by a running client', async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'snowluma-wal-'));
try {
const { image, wal, db } = await realWalDatabase(directory, [['a'], ['b']]);
const encryptedImage = encryptImage(image, SHA1_SHA512);
const encryptedWal = encryptWal(wal, encryptedImage, SHA1_SHA512);
const frameSize = FIXTURE_WAL_FRAME_HEADER_SIZE + FIXTURE_PAGE_SIZE;
const frameCount = (wal.length - FIXTURE_WAL_HEADER_SIZE) / frameSize;
// Shred the last frame's page: everything after the previous commit
// must be dropped rather than half-applied.
encryptedWal[encryptedWal.length - 8] ^= 0xff;
const decrypted = decryptDatabase(
encryptedImage,
encryptedWal,
FIXTURE_PASSPHRASE,
SHA1_SHA512,
);
expect(decrypted).not.toBeNull();
expect(decrypted!.walFrames).toBeLessThan(frameCount);
// Frames up to the tear survive; the merge still stops at the last
// commit they contain rather than applying a partial transaction.
expect(decrypted!.walFrames).toBe(committedFrames(wal, frameCount - 2));
// Everything the surviving commits wrote has to be in the image.
for (const [pageNumber, page] of committedPages(wal, frameCount - 2)) {
expectPageMatches(decrypted!.image, page, pageNumber, SHA1_SHA512);
}
await db.close();
} finally {
await rm(directory, { recursive: true, force: true });
}
});
});
describe('SQLCipher header handling', () => {
it('keeps the client header opaque and stores the salt at the start of page 1', () => {
const plain = plaintextImage(2);
const encrypted = encryptImage(plain, SHA1_SHA512);
const header = encrypted.subarray(0, FIXTURE_HEADER_SIZE);
expect(header.equals(Buffer.alloc(FIXTURE_HEADER_SIZE))).toBe(true);
expect(
encrypted
.subarray(FIXTURE_HEADER_SIZE + 16, FIXTURE_HEADER_SIZE + 48)
.equals(plain.subarray(16, 48)),
).toBe(false);
});
});
@@ -0,0 +1,86 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtemp, mkdir, rename, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
import { watchAccountFiles } from '../src/qq-account/watch';
// Keep native filesystem coverage aligned with qqDataRootsForPid's supported platforms.
// macOS is only a development host here: fs.watch() can return before FSEvents
// registration, losing immediate writes. Deterministic logic tests run on every OS.
describe.skipIf(process.platform !== 'win32' && process.platform !== 'linux')(
'account file changes on supported filesystems',
() => {
let root: string;
const subscriptions: Array<() => void> = [];
beforeEach(async () => {
root = await mkdtemp(path.join(os.tmpdir(), 'snowluma-account-watch-'));
});
afterEach(async () => {
for (const stop of subscriptions.splice(0)) {
stop();
}
await rm(root, { recursive: true, force: true });
});
function subscribe(dbPaths = [path.join(root, 'login.db')]) {
const changed = vi.fn();
subscriptions.push(watchAccountFiles(dbPaths, changed));
return changed;
}
it('observes database and WAL changes while ignoring message data and SHM', async () => {
const dbPath = path.join(root, 'login.db');
const changed = subscribe([dbPath, dbPath]);
await writeFile(dbPath, 'login');
await vi.waitFor(() => expect(changed).toHaveBeenCalled());
const afterDatabase = changed.mock.calls.length;
await writeFile(`${dbPath}-wal`, 'wal');
await vi.waitFor(() =>
expect(changed.mock.calls.length).toBeGreaterThan(afterDatabase),
);
const afterWal = changed.mock.calls.length;
await writeFile(path.join(root, 'nt_msg.db'), 'message');
await writeFile(`${dbPath}-shm`, 'shared memory');
await delay(350);
expect(changed).toHaveBeenCalledTimes(afterWal);
});
it('observes database replacement and WAL recreation', async () => {
const dbPath = path.join(root, 'login.db');
await writeFile(dbPath, 'initial');
const changed = subscribe();
await writeFile(path.join(root, 'replacement'), 'replacement');
await rename(path.join(root, 'replacement'), dbPath);
await vi.waitFor(() => expect(changed).toHaveBeenCalled());
const afterReplacement = changed.mock.calls.length;
await writeFile(dbPath, 'updated');
await vi.waitFor(() =>
expect(changed.mock.calls.length).toBeGreaterThan(afterReplacement),
);
const afterUpdate = changed.mock.calls.length;
await writeFile(`${dbPath}-wal`, 'wal');
await vi.waitFor(() => expect(changed.mock.calls.length).toBeGreaterThan(afterUpdate));
const afterWal = changed.mock.calls.length;
await rm(`${dbPath}-wal`);
await writeFile(`${dbPath}-wal`, 'new wal');
await vi.waitFor(() => expect(changed.mock.calls.length).toBeGreaterThan(afterWal));
});
it('starts watching login data created after QQ process discovery', async () => {
const dbPath = path.join(root, 'QQ', 'global', 'nt_db', 'login.db');
const changed = subscribe([dbPath]);
await mkdir(path.dirname(dbPath), { recursive: true });
await writeFile(dbPath, 'initial');
await vi.waitFor(() => expect(changed).toHaveBeenCalled());
const afterDatabase = changed.mock.calls.length;
await writeFile(`${dbPath}-wal`, 'login');
await vi.waitFor(() =>
expect(changed.mock.calls.length).toBeGreaterThan(afterDatabase),
);
});
},
);
@@ -0,0 +1,253 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { EventEmitter } from 'node:events';
import type { FSWatcher, WatchListener } from 'node:fs';
import path from 'node:path';
import { createLogger } from '@snowluma/common/logger';
import { watchAccountChangesForPids, watchAccountFiles } from '../src/qq-account/watch';
const files = vi.hoisted(() => ({
watch: vi.fn<
(
directory: string,
options: { persistent: boolean },
listener: WatchListener<string>,
) => FSWatcher
>(),
}));
vi.mock('node:fs', async (importOriginal) => ({
...(await importOriginal<typeof import('node:fs')>()),
watch: files.watch,
}));
class TestWatcher extends EventEmitter implements FSWatcher {
// Keep callbacks reachable after close to exercise queued, stale events.
close = vi.fn();
ref() {
return this;
}
unref() {
return this;
}
}
const root = path.resolve('account-watch-fixture');
const directories = new Set<string>();
const watchers: TestWatcher[] = [];
const subscriptions: Array<() => void> = [];
const platform = process.platform;
beforeEach(() => {
vi.useFakeTimers();
directories.add(root);
files.watch.mockImplementation((directory, _options, listener) => {
if (!directories.has(directory)) {
throw Object.assign(new Error('directory missing'), { code: 'ENOENT' });
}
const watcher = new TestWatcher();
watcher.on('change', listener);
watchers.push(watcher);
return watcher;
});
});
afterEach(() => {
for (const stop of subscriptions.splice(0)) {
stop();
}
Object.defineProperty(process, 'platform', { value: platform, configurable: true });
watchers.length = 0;
directories.clear();
files.watch.mockReset();
vi.useRealTimers();
vi.restoreAllMocks();
});
function subscribe(dbPaths = [path.join(root, 'login.db')]) {
const changed = vi.fn();
const log = createLogger('AccountWatchTest');
const warning = vi.spyOn(log, 'warn');
const stop = watchAccountFiles(dbPaths, changed, log);
subscriptions.push(stop);
return { changed, stop, warning };
}
// Native filesystem delivery is covered separately on the production platforms.
// These tests control event ordering and the debounce clock, not OS startup timing.
describe('account file changes', () => {
it('coalesces login database and WAL changes while ignoring message data and SHM', () => {
const dbPath = path.join(root, 'login.db');
const { changed } = subscribe([dbPath, dbPath]);
expect(files.watch).toHaveBeenCalledExactlyOnceWith(
root,
{ persistent: false },
expect.any(Function),
);
const watcher = watchers[0]!;
watcher.emit('change', 'change', 'login.db');
vi.advanceTimersByTime(100);
watcher.emit('change', 'change', 'login.db-wal');
vi.advanceTimersByTime(249);
expect(changed).not.toHaveBeenCalled();
watcher.emit('change', 'change', 'nt_msg.db');
watcher.emit('change', 'change', 'login.db-shm');
vi.advanceTimersByTime(1);
expect(changed).toHaveBeenCalledOnce();
vi.advanceTimersByTime(1000);
expect(changed).toHaveBeenCalledOnce();
});
it('keeps the directory watch across database replacement and WAL recreation', () => {
const { changed } = subscribe();
const watcher = watchers[0]!;
watcher.emit('change', 'rename', 'replacement');
vi.advanceTimersByTime(250);
expect(changed).not.toHaveBeenCalled();
watcher.emit('change', 'rename', 'login.db');
vi.advanceTimersByTime(250);
watcher.emit('change', 'change', 'login.db');
vi.advanceTimersByTime(250);
watcher.emit('change', 'rename', 'login.db-wal');
vi.advanceTimersByTime(250);
watcher.emit('change', 'rename', 'login.db-wal');
watcher.emit('change', 'rename', 'login.db-wal');
vi.advanceTimersByTime(250);
expect(changed).toHaveBeenCalledTimes(4);
expect(files.watch).toHaveBeenCalledOnce();
expect(watcher.close).not.toHaveBeenCalled();
});
it('moves to newly created directories one level at a time', () => {
const dbDirectory = path.join(root, 'QQ', 'global', 'nt_db');
const { changed } = subscribe([path.join(dbDirectory, 'login.db')]);
const ancestor = watchers[0]!;
const attempts = files.watch.mock.calls.length;
ancestor.emit('change', 'rename', 'unrelated');
ancestor.emit('change', 'change', 'login.db-wal');
expect(files.watch).toHaveBeenCalledTimes(attempts);
expect(vi.getTimerCount()).toBe(0);
let directory = root;
for (const segment of ['QQ', 'global', 'nt_db']) {
directory = path.join(directory, segment);
directories.add(directory);
const previous = watchers.at(-1)!;
previous.emit('change', 'rename', segment);
expect(previous.close).toHaveBeenCalledOnce();
expect(files.watch).toHaveBeenLastCalledWith(
directory,
{ persistent: false },
expect.any(Function),
);
}
vi.advanceTimersByTime(250);
expect(changed).toHaveBeenCalledOnce();
watchers.at(-1)!.emit('change', 'change', 'login.db-wal');
vi.advanceTimersByTime(250);
expect(changed).toHaveBeenCalledTimes(2);
});
it.each(['QQ', null])(
'observes an entire new directory tree from a %s ancestor event',
(filename) => {
const dbDirectory = path.join(root, 'QQ', 'global', 'nt_db');
const { changed, warning } = subscribe([path.join(dbDirectory, 'login.db')]);
const ancestor = watchers[0]!;
directories.add(dbDirectory);
ancestor.emit('change', 'rename', filename);
expect(ancestor.close).toHaveBeenCalledOnce();
expect(files.watch).toHaveBeenLastCalledWith(
dbDirectory,
{ persistent: false },
expect.any(Function),
);
const attempts = files.watch.mock.calls.length;
vi.advanceTimersByTime(250);
expect(changed).toHaveBeenCalledOnce();
ancestor.emit('change', 'rename', 'QQ');
ancestor.emit('error', new Error('late error'));
vi.advanceTimersByTime(250);
expect(files.watch).toHaveBeenCalledTimes(attempts);
expect(changed).toHaveBeenCalledOnce();
expect(warning).not.toHaveBeenCalled();
watchers.at(-1)!.emit('change', 'change', 'login.db-wal');
vi.advanceTimersByTime(250);
expect(changed).toHaveBeenCalledTimes(2);
},
);
it('notifies when the filesystem does not supply a filename', () => {
const { changed } = subscribe();
watchers[0]!.emit('change', 'change', null);
vi.advanceTimersByTime(250);
expect(changed).toHaveBeenCalledOnce();
});
it('closes every watcher and cancels pending notifications on disposal', () => {
const { changed, stop, warning } = subscribe([
path.join(root, 'login.db'),
path.join(root, 'other', 'login.db'),
]);
expect(watchers).toHaveLength(2);
watchers[0]!.emit('change', 'change', 'login.db');
vi.advanceTimersByTime(30);
expect(vi.getTimerCount()).toBe(1);
stop();
expect(vi.getTimerCount()).toBe(0);
for (const watcher of watchers) {
expect(watcher.close).toHaveBeenCalledOnce();
watcher.emit('change', 'rename', null);
watcher.emit('error', new Error('late error'));
}
vi.advanceTimersByTime(1000);
expect(changed).not.toHaveBeenCalled();
expect(warning).not.toHaveBeenCalled();
});
it('reports watch failures without starting a polling fallback', () => {
files.watch.mockImplementationOnce(() => {
throw Object.assign(new Error('access denied'), { code: 'EACCES' });
});
const { changed, warning } = subscribe();
expect(warning).toHaveBeenCalledWith(
'account watch failed: path=%s err=%s',
path.join(root, 'login.db'),
'access denied',
);
expect(vi.getTimerCount()).toBe(0);
vi.advanceTimersByTime(1000);
expect(files.watch).toHaveBeenCalledOnce();
expect(changed).not.toHaveBeenCalled();
});
it('reports asynchronous watch errors and closes the failed watcher', () => {
const { changed, warning } = subscribe();
const watcher = watchers[0]!;
watcher.emit('error', new Error('watch unavailable'));
expect(watcher.close).toHaveBeenCalledOnce();
expect(warning).toHaveBeenCalledWith(
'account watch failed: path=%s err=%s',
path.join(root, 'login.db'),
'watch unavailable',
);
watcher.emit('change', 'change', 'login.db');
vi.advanceTimersByTime(250);
expect(changed).not.toHaveBeenCalled();
});
it('does not start account file watchers through the production entry point on macOS', () => {
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
const changed = vi.fn();
subscriptions.push(watchAccountChangesForPids([123], changed));
expect(files.watch).not.toHaveBeenCalled();
expect(vi.getTimerCount()).toBe(0);
expect(changed).not.toHaveBeenCalled();
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,152 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { hasLowProcessIntegrity, type NativeRuntimeIntegrity } from '../src/runtime-integrity';
const native = vi.hoisted(() => ({ load: vi.fn(), error: vi.fn() }));
vi.mock('../src/injector', () => ({
getNativeHookAddon: native.load,
getNativeHookLoadError: native.error,
}));
const platform = process.platform;
const sample = (): NativeRuntimeIntegrity => ({
pid: process.pid,
executablePath: process.execPath,
process: { status: 'ok', rid: 8192 },
file: { status: 'ok', label: null },
});
beforeEach(() => {
vi.resetModules();
vi.resetAllMocks();
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
});
afterEach(() => {
Object.defineProperty(process, 'platform', { value: platform, configurable: true });
});
describe('runtime integrity diagnostics', () => {
it('warns only for a successfully read process token below Medium', () => {
const available = { ...sample(), status: 'available' as const };
for (const rid of [0, 4096]) {
expect(hasLowProcessIntegrity({ ...available, process: { status: 'ok', rid } })).toBe(
true,
);
}
for (const rid of [8192, 8448, 12288, 16384]) {
expect(
hasLowProcessIntegrity({
...available,
process: { status: 'ok', rid },
file: { status: 'ok', label: { rid: 4096, inherited: true, policy: 1 } },
}),
).toBe(false);
}
expect(hasLowProcessIntegrity({ status: 'unsupported' })).toBe(false);
expect(hasLowProcessIntegrity({ status: 'unavailable', error: 'unavailable' })).toBe(false);
expect(
hasLowProcessIntegrity({
...available,
process: { status: 'error', operation: 'OpenProcessToken', code: 5 },
}),
).toBe(false);
});
it('keeps one startup snapshot for logging and all WebUI polls', async () => {
const read = vi.fn().mockResolvedValue(sample());
native.load.mockReturnValue({ getRuntimeIntegrityAsync: read });
const { getRuntimeIntegrity } = await import('../src/runtime-integrity');
const pending = getRuntimeIntegrity();
expect(getRuntimeIntegrity()).toBe(pending);
const first = await pending;
expect(first).toMatchObject({
status: 'available',
process: { rid: 8192 },
file: { label: null },
});
read.mockResolvedValue({
...sample(),
file: { status: 'ok', label: { rid: 4096, inherited: true, policy: 1 } },
});
expect(await getRuntimeIntegrity()).toBe(first);
expect(read).toHaveBeenCalledTimes(1);
});
it.each(['process', 'file'])(
'retains %s query errors and the other successful result',
async (field) => {
native.load.mockReturnValue({
getRuntimeIntegrityAsync: async () => ({
...sample(),
[field]: { status: 'error', operation: 'Win32Query', code: 5 },
}),
});
const { getRuntimeIntegrity } = await import('../src/runtime-integrity');
expect(await getRuntimeIntegrity()).toMatchObject({
status: 'available',
[field]: { status: 'error', operation: 'Win32Query', code: 5 },
});
},
);
it('reports old addons as unavailable rather than Medium', async () => {
native.load.mockReturnValue({});
const { getRuntimeIntegrity } = await import('../src/runtime-integrity');
expect(await getRuntimeIntegrity()).toMatchObject({
status: 'unavailable',
error: expect.stringContaining('不支持'),
});
});
it('retains the loader error for the startup log', async () => {
native.load.mockReturnValue(null);
native.error.mockReturnValue('dlopen failed: incompatible binary');
const { getRuntimeIntegrity } = await import('../src/runtime-integrity');
expect(await getRuntimeIntegrity()).toMatchObject({
status: 'unavailable',
error: 'dlopen failed: incompatible binary',
});
});
it.each([
null,
{ ...sample(), pid: process.pid + 1 },
{ ...sample(), process: { status: 'ok', rid: -1 } },
{ ...sample(), file: { status: 'ok', label: { rid: 4096, inherited: 'yes', policy: 1 } } },
])('rejects malformed or wrong-process native results', async (result) => {
native.load.mockReturnValue({ getRuntimeIntegrityAsync: async () => result });
const { getRuntimeIntegrity } = await import('../src/runtime-integrity');
expect(await getRuntimeIntegrity()).toMatchObject({
status: 'unavailable',
error: expect.stringContaining('无效数据'),
});
});
it('preserves rejected errors without repeatedly probing on WebUI polls', async () => {
const read = vi
.fn()
.mockRejectedValueOnce(new Error('allocation failed'))
.mockResolvedValue(sample());
native.load.mockReturnValue({ getRuntimeIntegrityAsync: read });
const { getRuntimeIntegrity } = await import('../src/runtime-integrity');
expect(await getRuntimeIntegrity()).toMatchObject({
status: 'unavailable',
error: 'allocation failed',
});
expect(await getRuntimeIntegrity()).toMatchObject({
status: 'unavailable',
error: 'allocation failed',
});
expect(read).toHaveBeenCalledTimes(1);
});
it('does not load the addon on non-Windows hosts', async () => {
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
const { getRuntimeIntegrity } = await import('../src/runtime-integrity');
expect(await getRuntimeIntegrity()).toEqual({ status: 'unsupported' });
expect(native.load).not.toHaveBeenCalled();
});
});