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
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:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"name": "@snowluma/bridge",
|
||||
"version": "2.4.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "SnowLuma bridge — QQ NT desktop client lifecycle: hook injection (via native addon), per-PID HookSession orchestration, pipe-watcher for process discovery, and the BridgeManagerSink interface that lets the hook layer call back into a UIN-keyed session router without depending on @snowluma/core. Consumed by the top-level app entry (which constructs the BridgeManager from @snowluma/core and injects it as the sink) and by the WebUI server (read-only HookProcessInfo views).",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./hook-manager": {
|
||||
"types": "./src/hook-manager.ts",
|
||||
"default": "./src/hook-manager.ts"
|
||||
},
|
||||
"./hook-session": {
|
||||
"types": "./src/hook-session.ts",
|
||||
"default": "./src/hook-session.ts"
|
||||
},
|
||||
"./hook-packet-client": {
|
||||
"types": "./src/hook-packet-client.ts",
|
||||
"default": "./src/hook-packet-client.ts"
|
||||
},
|
||||
"./packet-timeout": {
|
||||
"types": "./src/packet-timeout.ts",
|
||||
"default": "./src/packet-timeout.ts"
|
||||
},
|
||||
"./injector": {
|
||||
"types": "./src/injector.ts",
|
||||
"default": "./src/injector.ts"
|
||||
},
|
||||
"./pipe-watcher": {
|
||||
"types": "./src/pipe-watcher.ts",
|
||||
"default": "./src/pipe-watcher.ts"
|
||||
},
|
||||
"./qq-hook-client": {
|
||||
"types": "./src/qq-hook-client.ts",
|
||||
"default": "./src/qq-hook-client.ts"
|
||||
},
|
||||
"./qq-account": {
|
||||
"types": "./src/qq-account/index.ts",
|
||||
"default": "./src/qq-account/index.ts"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./src/types.ts",
|
||||
"default": "./src/types.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@snowluma/common": "workspace:*",
|
||||
"@snowluma/sqlite": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:",
|
||||
"vitest": "4.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^24.22.1 || >=26.12.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,704 @@
|
||||
import { errorMessage } from '@snowluma/common/errors';
|
||||
import { createLogger, runWithTraceRequest, type Logger } from '@snowluma/common/logger';
|
||||
import { renderParamsVerbose } from '@snowluma/common/log-summary';
|
||||
import type { PacketSender } from '@snowluma/common/packet-sender';
|
||||
import type { PacketInfo, PacketSink } from '@snowluma/common/protocol-types';
|
||||
import fs from 'fs';
|
||||
import { HookSession, type HookSessionDeps } from './hook-session';
|
||||
import {
|
||||
injectHookProcess,
|
||||
listHookProcesses,
|
||||
unloadHookProcess,
|
||||
type HookProcessBaseInfo,
|
||||
} from './injector';
|
||||
import { PipeWatcher } from './pipe-watcher';
|
||||
import { createProcessEnumerator, type ProcessEnumerator } from './process-enumerator';
|
||||
import { QqHookClient } from './qq-hook-client';
|
||||
import {
|
||||
detectAccountLoginsForPids,
|
||||
watchAccountChangesForPids,
|
||||
type AccountLoginState,
|
||||
} from './qq-account';
|
||||
import type { HookProcessAccount, HookProcessInfo } from './types';
|
||||
|
||||
const AUTO_LOAD_MAX_ATTEMPTS = 3;
|
||||
|
||||
type AutoLoadAttempt = {
|
||||
attempts: number;
|
||||
inFlight: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sink that the hook layer calls back into when it observes a new login,
|
||||
* a parsed packet, or a PID disconnect. The concrete implementation lives
|
||||
* in @snowluma/core (`BridgeManager`), which is wired in by the top-level
|
||||
* app entry. Declaring it here keeps @snowluma/bridge free of any
|
||||
* dependency on @snowluma/core — the hook package is self-contained.
|
||||
*/
|
||||
export interface BridgeManagerSink {
|
||||
onHookLogin(pid: number, uin: string, packetClient: PacketSender): void;
|
||||
onPacket(pkt: PacketInfo): void;
|
||||
onPidDisconnected(pid: number): void;
|
||||
onPidReceiveHealthChanged(pid: number, healthy: boolean): void;
|
||||
}
|
||||
|
||||
export type HookManagerDeps = {
|
||||
bridgeManager: BridgeManagerSink;
|
||||
/** Sink for parsed packets from any live HookSession. Defaults to
|
||||
* `bridgeManager.onPacket` — every packet flows straight into the
|
||||
* per-UIN bridge dispatcher with no intermediate event emitter. */
|
||||
onPacket?: PacketSink;
|
||||
/** Native injector entrypoints. Defaults to the real native addon. */
|
||||
injector?: HookSessionDeps['injector'];
|
||||
/** Hook pipe-client factory. Defaults to `new QqHookClient(pid)`. */
|
||||
makeClient?: HookSessionDeps['makeClient'];
|
||||
/** Pre-built watcher. Defaults to a PipeWatcher wrapping the native listings. */
|
||||
pipeWatcher?: PipeWatcher;
|
||||
/** Polling interval for the default watcher. Ignored if `pipeWatcher` is provided. */
|
||||
watcherIntervalMs?: number;
|
||||
/** Native process lister used by `listProcesses()`. Defaults to the native addon. */
|
||||
listProcesses?: () => Promise<HookProcessBaseInfo[]>;
|
||||
/** Local account detection used to label each process with the account QQ is
|
||||
* signed in as. Receives the pids to label so each one is resolved against
|
||||
* the HOME it actually runs under (a container can give every QQ instance
|
||||
* its own). Defaults to reading QQ's own account data. */
|
||||
detectAccounts?: (pids: readonly number[]) => Promise<AccountLoginState[]>;
|
||||
/** Subscribe to login database changes. Returns the subscription cleanup. */
|
||||
watchAccounts?: (pids: readonly number[], onChange: () => void) => () => void;
|
||||
/** When true, every newly-discovered QQ process is auto-injected (fires
|
||||
* `loadProcess(pid)` from the watcher's 'process-discovered' handler).
|
||||
* Failed loads are logged and leave the session in the 'error' state.
|
||||
* A narrowly-classified early-process mapping race is retried on later
|
||||
* watcher ticks with a fixed attempt limit. */
|
||||
autoLoadOnDiscovery?: boolean;
|
||||
/** Optional hook fired whenever the set of HookProcessInfo observable to
|
||||
* `listProcesses()` changes — new process discovered, process gone, or
|
||||
* any session's status mutated. Used by the WebUI SSE wiring to push a
|
||||
* fresh processes snapshot to connected clients without REST polling.
|
||||
* Exceptions thrown by the callback are caught and logged; they do not
|
||||
* break the watcher / session event loops. */
|
||||
onSessionsChanged?: () => void;
|
||||
log?: Logger;
|
||||
};
|
||||
|
||||
/**
|
||||
* HookManager — thin orchestrator over a per-PID HookSession map and a
|
||||
* singleton PipeWatcher.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Route user commands (load/unload/refresh) to the matching session.
|
||||
* - Route watcher diff events to the matching session.
|
||||
* - Forward session events ('login' / 'disconnected' / transport health) to BridgeManager.
|
||||
* - Retry stuck-in-connecting sessions on every watcher tick (so a
|
||||
* failed connect eventually recovers without a manual refresh).
|
||||
* - Retry the bounded early-process auto-load race without retrying
|
||||
* permanent injector errors.
|
||||
*
|
||||
* The native injector, native pipe-client, and native process/pipe
|
||||
* listings are all swappable dependencies so tests can run without a
|
||||
* real QQ.exe or a native addon.
|
||||
*/
|
||||
export class HookManager {
|
||||
private readonly bridgeManager: BridgeManagerSink;
|
||||
private readonly onPacket: PacketSink;
|
||||
private readonly injector: HookSessionDeps['injector'];
|
||||
private readonly makeClient: HookSessionDeps['makeClient'];
|
||||
private readonly pipeWatcher: PipeWatcher;
|
||||
private readonly ownsPipeWatcher: boolean;
|
||||
private readonly enumerator: ProcessEnumerator;
|
||||
private readonly detectAccounts: (pids: readonly number[]) => Promise<AccountLoginState[]>;
|
||||
private readonly watchAccounts: NonNullable<HookManagerDeps['watchAccounts']>;
|
||||
private readonly autoLoadOnDiscovery: boolean;
|
||||
private readonly onSessionsChangedRaw?: () => void;
|
||||
private readonly log: Logger;
|
||||
private readonly sessions = new Map<number, HookSession>();
|
||||
private readonly autoLoadAttempts = new Map<number, AutoLoadAttempt>();
|
||||
private readonly startPromise: Promise<void>;
|
||||
|
||||
private accounts = new Map<number, HookProcessAccount>();
|
||||
private accountProbe: Promise<void> | null = null;
|
||||
private accountsDirty = true;
|
||||
private stopWatchingAccounts?: () => void;
|
||||
private lastAccountProbeFailure = '';
|
||||
|
||||
private disposed = false;
|
||||
|
||||
constructor(deps: HookManagerDeps) {
|
||||
this.bridgeManager = deps.bridgeManager;
|
||||
this.onPacket = deps.onPacket ?? ((pkt) => deps.bridgeManager.onPacket(pkt));
|
||||
this.onSessionsChangedRaw = deps.onSessionsChanged;
|
||||
this.log = deps.log ?? createLogger('Hook');
|
||||
|
||||
this.injector = deps.injector ?? {
|
||||
inject: injectHookProcess,
|
||||
unload: unloadHookProcess,
|
||||
};
|
||||
this.makeClient = deps.makeClient ?? ((pid: number) => new QqHookClient(pid));
|
||||
this.detectAccounts = deps.detectAccounts ?? detectAccountLoginsForPids;
|
||||
this.watchAccounts =
|
||||
deps.watchAccounts ??
|
||||
((pids, onChange) => watchAccountChangesForPids(pids, onChange, this.log));
|
||||
this.autoLoadOnDiscovery = deps.autoLoadOnDiscovery ?? false;
|
||||
|
||||
this.enumerator = createProcessEnumerator({
|
||||
listProcesses: deps.listProcesses ?? listHookProcesses,
|
||||
log: this.log,
|
||||
});
|
||||
|
||||
if (deps.pipeWatcher) {
|
||||
this.pipeWatcher = deps.pipeWatcher;
|
||||
this.ownsPipeWatcher = false;
|
||||
} else {
|
||||
this.pipeWatcher = new PipeWatcher({
|
||||
listProcesses: this.enumerator.enumerate,
|
||||
listLivePipes: (processes) =>
|
||||
QqHookClient.listLivePipes(processes.map(({ pid }) => pid)),
|
||||
intervalMs: deps.watcherIntervalMs,
|
||||
log: this.log,
|
||||
});
|
||||
this.ownsPipeWatcher = true;
|
||||
}
|
||||
|
||||
this.bindWatcher();
|
||||
this.startPromise = this.pipeWatcher.start();
|
||||
}
|
||||
|
||||
// ─────────────── public API (unchanged from prior HookManager) ───────────────
|
||||
|
||||
async listProcesses(): Promise<HookProcessInfo[]> {
|
||||
await this.startPromise;
|
||||
const processes = await this.enumerator.enumerate();
|
||||
// A failed enumeration preserves the last known process set.
|
||||
const sessions =
|
||||
processes === null
|
||||
? [...this.sessions.values()]
|
||||
: processes.map((proc) => {
|
||||
const session = this.ensureSession(proc.pid);
|
||||
session.attachProcessInfo(proc);
|
||||
return session;
|
||||
});
|
||||
await this.refreshAccounts();
|
||||
return sessions
|
||||
.filter((session) => this.sessions.get(session.pid) === session)
|
||||
.map((session) => this.processInfo(session))
|
||||
.sort((a, b) => a.pid - b.pid);
|
||||
}
|
||||
|
||||
async loadProcess(pid: number): Promise<HookProcessInfo> {
|
||||
this.assertValidPid(pid);
|
||||
await this.startPromise;
|
||||
this.autoLoadAttempts.delete(pid);
|
||||
const session = this.ensureSession(pid);
|
||||
this.invalidateAccounts();
|
||||
await session.load();
|
||||
this.pipeWatcher.wake();
|
||||
await this.refreshAccounts();
|
||||
return this.processInfo(session);
|
||||
}
|
||||
|
||||
async unloadProcess(pid: number): Promise<HookProcessInfo> {
|
||||
this.assertValidPid(pid);
|
||||
await this.startPromise;
|
||||
this.autoLoadAttempts.delete(pid);
|
||||
const session = this.ensureSession(pid);
|
||||
this.invalidateAccounts();
|
||||
await session.unload();
|
||||
await this.refreshAccounts();
|
||||
return this.processInfo(session);
|
||||
}
|
||||
|
||||
async refreshProcess(pid: number): Promise<HookProcessInfo> {
|
||||
this.assertValidPid(pid);
|
||||
await this.startPromise;
|
||||
const session = this.ensureSession(pid);
|
||||
this.updateAccountWatcher();
|
||||
this.invalidateAccounts();
|
||||
await session.refresh();
|
||||
await this.refreshAccounts();
|
||||
return this.processInfo(session);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.disposed = true;
|
||||
this.stopWatchingAccounts?.();
|
||||
|
||||
for (const session of this.sessions.values()) {
|
||||
session.dispose();
|
||||
}
|
||||
|
||||
this.sessions.clear();
|
||||
this.accounts.clear();
|
||||
this.autoLoadAttempts.clear();
|
||||
this.enumerator.dispose();
|
||||
|
||||
if (this.ownsPipeWatcher) {
|
||||
this.pipeWatcher.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────── wiring ───────────────
|
||||
|
||||
private processInfo(session: HookSession): HookProcessInfo {
|
||||
return { ...session.toInfo(), account: this.accounts.get(session.pid) ?? null };
|
||||
}
|
||||
|
||||
private invalidateAccounts(): void {
|
||||
this.accountsDirty = true;
|
||||
}
|
||||
|
||||
private updateAccountWatcher(): void {
|
||||
this.stopWatchingAccounts?.();
|
||||
this.stopWatchingAccounts = this.watchAccounts([...this.sessions.keys()], () => {
|
||||
this.invalidateAccounts();
|
||||
void this.refreshAccounts();
|
||||
});
|
||||
}
|
||||
|
||||
/** REST, watcher ticks and login reconciliation share the same account snapshot. */
|
||||
private async refreshAccounts(): Promise<void> {
|
||||
if (this.disposed || this.sessions.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.accountProbe) {
|
||||
await this.accountProbe;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.accountsDirty) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.accountProbe = runWithTraceRequest(async () => {
|
||||
// All callers await the replacement read too if a lifecycle change
|
||||
// invalidates an in-flight result.
|
||||
do {
|
||||
this.accountsDirty = false;
|
||||
await this.readAccounts();
|
||||
} while (!this.disposed && this.sessions.size > 0 && this.accountsDirty);
|
||||
}).finally(() => {
|
||||
this.accountProbe = null;
|
||||
});
|
||||
await this.accountProbe;
|
||||
}
|
||||
|
||||
private async readAccounts(): Promise<void> {
|
||||
const accounts = new Map<number, HookProcessAccount>();
|
||||
|
||||
try {
|
||||
for (const state of await this.detectAccounts([...this.sessions.keys()])) {
|
||||
if (state.pid !== null && this.sessions.has(state.pid)) {
|
||||
accounts.set(state.pid, { uin: state.uin, nickname: state.nickname });
|
||||
}
|
||||
}
|
||||
|
||||
this.lastAccountProbeFailure = '';
|
||||
} catch (error) {
|
||||
const message = errorMessage(error);
|
||||
|
||||
if (this.lastAccountProbeFailure !== message) {
|
||||
this.lastAccountProbeFailure = message;
|
||||
this.log.warn('account detection failed: %s', message);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.disposed || this.accountsDirty) {
|
||||
return;
|
||||
}
|
||||
|
||||
const changed =
|
||||
accounts.size !== this.accounts.size ||
|
||||
[...accounts].some(([pid, account]) => {
|
||||
const previous = this.accounts.get(pid);
|
||||
return previous?.uin !== account.uin || previous.nickname !== account.nickname;
|
||||
});
|
||||
this.accounts = accounts;
|
||||
|
||||
if (changed) {
|
||||
this.notifySessionsChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private bindWatcher(): void {
|
||||
this.pipeWatcher.on('process-discovered', (info: HookProcessBaseInfo) => {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
runWithTraceRequest(() => {
|
||||
const session = this.ensureSession(info.pid);
|
||||
session.attachProcessInfo(info);
|
||||
this.log.trace(() => [
|
||||
'hook_manager_fact event=process_discovered pid=%d info=%s autoLoad=%s',
|
||||
info.pid,
|
||||
renderParamsVerbose(info),
|
||||
this.autoLoadOnDiscovery,
|
||||
]);
|
||||
|
||||
// Headless/Docker deployments enable autoLoadOnDiscovery so QQ gets
|
||||
// injected without a human clicking "Load" in WebUI. Fire-and-forget:
|
||||
// failures are already captured inside loadInternal and surfaced via
|
||||
// the session's status field.
|
||||
if (this.autoLoadOnDiscovery && shouldAutoLoadPid(info.pid, this.log)) {
|
||||
this.runAutoLoad(session);
|
||||
}
|
||||
|
||||
this.notifySessionsChanged();
|
||||
});
|
||||
});
|
||||
this.pipeWatcher.on('process-gone', (pid: number) => {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
runWithTraceRequest(() => {
|
||||
this.autoLoadAttempts.delete(pid);
|
||||
const session = this.sessions.get(pid);
|
||||
this.log.trace(
|
||||
'hook_manager_fact event=process_gone pid=%d tracked=%s',
|
||||
pid,
|
||||
session !== undefined,
|
||||
);
|
||||
|
||||
if (session) {
|
||||
session.notifyProcessGone();
|
||||
}
|
||||
|
||||
this.notifySessionsChanged();
|
||||
});
|
||||
});
|
||||
this.pipeWatcher.on('pipe-up', (pid: number) => {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const session = this.sessions.get(pid);
|
||||
|
||||
if (session) {
|
||||
this.invalidateAccounts();
|
||||
session.onPipeUp();
|
||||
}
|
||||
});
|
||||
this.pipeWatcher.on('pipe-down', (pid: number) => {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const session = this.sessions.get(pid);
|
||||
|
||||
if (session) {
|
||||
this.invalidateAccounts();
|
||||
session.onPipeDown();
|
||||
}
|
||||
});
|
||||
// After every tick, retry sessions that are stuck in 'connecting' OR
|
||||
// 'disconnected' while the pipe is up. Mirrors the original
|
||||
// tickWatcher's per-tick reconcileConnect pass; onPipeUp is idempotent
|
||||
// so calling it for an already-connected session is harmless.
|
||||
//
|
||||
// The 'disconnected' branch matters on Windows: when the QQ-NT side of
|
||||
// the named pipe stays alive but the SnowLuma-side socket dies (read
|
||||
// EPIPE / idle-timeout / similar transient I/O fault), the watcher
|
||||
// sees the pipe as still-live (the OS-level pipe is still listed by
|
||||
// `listLivePipes`) so it never fires another `pipe-up`. Without
|
||||
// retrying 'disconnected' sessions here the session is stranded
|
||||
// forever — a real-world symptom users reported as "Windows running
|
||||
// for a while then stops" with logs `pipe error: read EPIPE` →
|
||||
// `session closed: UIN=…`.
|
||||
this.pipeWatcher.on('tick', () => {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
void this.refreshAccounts();
|
||||
|
||||
for (const session of this.sessions.values()) {
|
||||
const autoLoad = this.autoLoadAttempts.get(session.pid);
|
||||
|
||||
if (
|
||||
autoLoad &&
|
||||
!autoLoad.inFlight &&
|
||||
session.status === 'error' &&
|
||||
isTransientLibcMappingError(session.error)
|
||||
) {
|
||||
this.runAutoLoad(session, autoLoad);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
(session.status === 'connecting' ||
|
||||
session.status === 'disconnected' ||
|
||||
session.status === 'error') &&
|
||||
this.pipeWatcher.isPipeLive(session.pid)
|
||||
) {
|
||||
session.onPipeUp();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private runAutoLoad(session: HookSession, existing?: AutoLoadAttempt): void {
|
||||
if (this.disposed || session.isDisposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = existing ?? { attempts: 0, inFlight: false };
|
||||
|
||||
if (state.inFlight || state.attempts >= AUTO_LOAD_MAX_ATTEMPTS) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.attempts += 1;
|
||||
state.inFlight = true;
|
||||
this.autoLoadAttempts.set(session.pid, state);
|
||||
const attempt = state.attempts;
|
||||
|
||||
void runWithTraceRequest(async () => {
|
||||
const startedAt = Date.now();
|
||||
this.log.trace(
|
||||
'hook_autoload_start pid=%d attempt=%d maxAttempts=%d',
|
||||
session.pid,
|
||||
attempt,
|
||||
AUTO_LOAD_MAX_ATTEMPTS,
|
||||
);
|
||||
|
||||
try {
|
||||
const info = await session.load();
|
||||
|
||||
if (this.autoLoadAttempts.get(session.pid) !== state) {
|
||||
this.log.trace(
|
||||
'hook_autoload_terminal pid=%d attempt=%d outcome=dropped reason=superseded elapsedMs=%d',
|
||||
session.pid,
|
||||
attempt,
|
||||
Date.now() - startedAt,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
state.inFlight = false;
|
||||
|
||||
if (info.status !== 'error') {
|
||||
this.autoLoadAttempts.delete(session.pid);
|
||||
const reason = attempt > 1 ? 'recovered' : 'loaded';
|
||||
this.log.trace(() => [
|
||||
'hook_autoload_terminal pid=%d attempt=%d outcome=completed reason=%s state=%s elapsedMs=%d',
|
||||
session.pid,
|
||||
attempt,
|
||||
reason,
|
||||
renderParamsVerbose(info),
|
||||
Date.now() - startedAt,
|
||||
]);
|
||||
|
||||
if (attempt > 1) {
|
||||
this.log.info(
|
||||
'auto-load recovered: PID=%d attempt=%d/%d',
|
||||
session.pid,
|
||||
attempt,
|
||||
AUTO_LOAD_MAX_ATTEMPTS,
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isTransientLibcMappingError(info.error)) {
|
||||
this.autoLoadAttempts.delete(session.pid);
|
||||
this.log.trace(
|
||||
'hook_autoload_terminal pid=%d attempt=%d outcome=failed reason=permanent_failure error=%j elapsedMs=%d',
|
||||
session.pid,
|
||||
attempt,
|
||||
info.error,
|
||||
Date.now() - startedAt,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (attempt >= AUTO_LOAD_MAX_ATTEMPTS) {
|
||||
this.autoLoadAttempts.delete(session.pid);
|
||||
this.log.trace(
|
||||
'hook_autoload_terminal pid=%d attempt=%d outcome=failed reason=retry_exhausted error=%j elapsedMs=%d',
|
||||
session.pid,
|
||||
attempt,
|
||||
info.error,
|
||||
Date.now() - startedAt,
|
||||
);
|
||||
this.log.warn(
|
||||
'auto-load retry exhausted: PID=%d attempts=%d err=%s',
|
||||
session.pid,
|
||||
attempt,
|
||||
info.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.log.trace(
|
||||
'hook_autoload_terminal pid=%d attempt=%d outcome=failed reason=retry_pending error=%j elapsedMs=%d',
|
||||
session.pid,
|
||||
attempt,
|
||||
info.error,
|
||||
Date.now() - startedAt,
|
||||
);
|
||||
this.log.warn(
|
||||
'auto-load retry pending: PID=%d attempt=%d/%d err=%s',
|
||||
session.pid,
|
||||
attempt,
|
||||
AUTO_LOAD_MAX_ATTEMPTS,
|
||||
info.error,
|
||||
);
|
||||
} catch (err) {
|
||||
if (this.autoLoadAttempts.get(session.pid) === state) {
|
||||
this.autoLoadAttempts.delete(session.pid);
|
||||
}
|
||||
|
||||
this.log.trace(
|
||||
'hook_autoload_terminal pid=%d attempt=%d outcome=failed reason=unexpected_failure error=%j elapsedMs=%d',
|
||||
session.pid,
|
||||
attempt,
|
||||
errorMessage(err),
|
||||
Date.now() - startedAt,
|
||||
);
|
||||
this.log.warn('auto-load failed: PID=%d err=%s', session.pid, errorMessage(err));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private ensureSession(pid: number): HookSession {
|
||||
let session = this.sessions.get(pid);
|
||||
|
||||
if (session) {
|
||||
return session;
|
||||
}
|
||||
|
||||
session = new HookSession(pid, {
|
||||
injector: this.injector,
|
||||
makeClient: this.makeClient,
|
||||
pipeWatcher: this.pipeWatcher,
|
||||
onPacket: this.onPacket,
|
||||
detectLoginUin: async () => {
|
||||
await this.refreshAccounts();
|
||||
return this.accounts.get(pid)?.uin ?? null;
|
||||
},
|
||||
log: this.log,
|
||||
});
|
||||
session.attachProcessInfo({ name: defaultProcessName() });
|
||||
|
||||
session.on('login', (uin: string, sender) => {
|
||||
// Account switches can keep the same 'online' status.
|
||||
this.invalidateAccounts();
|
||||
this.bridgeManager.onHookLogin(pid, uin, sender);
|
||||
this.notifySessionsChanged();
|
||||
});
|
||||
session.on('disconnected', (wasLoggedIn: boolean) => {
|
||||
if (wasLoggedIn) {
|
||||
this.bridgeManager.onPidDisconnected(pid);
|
||||
}
|
||||
});
|
||||
session.on('receive-health-changed', (healthy: boolean) => {
|
||||
this.bridgeManager.onPidReceiveHealthChanged(pid, healthy);
|
||||
});
|
||||
let previousStatus = session.status;
|
||||
session.on('status-changed', (status) => {
|
||||
if (previousStatus === 'online' && status !== 'online') {
|
||||
this.invalidateAccounts();
|
||||
}
|
||||
|
||||
previousStatus = status;
|
||||
this.notifySessionsChanged();
|
||||
});
|
||||
session.on('disposed', () => {
|
||||
this.sessions.delete(pid);
|
||||
this.accounts.delete(pid);
|
||||
this.updateAccountWatcher();
|
||||
this.invalidateAccounts();
|
||||
});
|
||||
|
||||
this.sessions.set(pid, session);
|
||||
this.updateAccountWatcher();
|
||||
this.invalidateAccounts();
|
||||
return session;
|
||||
}
|
||||
|
||||
/** Fire the optional sessions-changed hook with exceptions isolated.
|
||||
* A throwing subscriber must not abort the watcher's emit loop or break
|
||||
* a HookSession event handler in flight. */
|
||||
private notifySessionsChanged(): void {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.onSessionsChangedRaw) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.onSessionsChangedRaw();
|
||||
} catch (err) {
|
||||
this.log.warn('onSessionsChanged threw: %s', errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
private assertValidPid(pid: number): void {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
throw new Error('invalid pid');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function defaultProcessName(): string {
|
||||
return process.platform === 'win32' ? 'QQ.exe' : 'qq';
|
||||
}
|
||||
|
||||
function isTransientLibcMappingError(error: string): boolean {
|
||||
return /^target process does not map \S*\/libc\.so\.6 while resolving mmap$/.test(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter for auto-load only. Returns false for Linux Electron child
|
||||
* processes (zygote/renderer/gpu/utility), which the native enumerator
|
||||
* mis-classifies as "main QQ" because their cmdline contains "qq" and
|
||||
* they transiently inherit wrapper.node via fork copy-on-write.
|
||||
*
|
||||
* Injecting into a zygote spawns the hook's resolver thread inside it;
|
||||
* every renderer Electron later forks then inherits that thread + the
|
||||
* partially-init'd hook state, which breaks QQ's IPC and login UI.
|
||||
*
|
||||
* Manual loadProcess() bypasses this gate — the operator is responsible
|
||||
* for picking the right PID from WebUI.
|
||||
*/
|
||||
export function shouldAutoLoadPid(pid: number, log: Logger): boolean {
|
||||
if (process.platform !== 'linux') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Escape hatch: operators who explicitly want auto-load on every
|
||||
// enumerated PID can set this to opt out of the filter.
|
||||
if (process.env.SNOWLUMA_HOOK_AUTOLOAD_ALL === '1') {
|
||||
return true;
|
||||
}
|
||||
|
||||
let cmdline: string;
|
||||
|
||||
try {
|
||||
cmdline = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8');
|
||||
} catch {
|
||||
// Can't read cmdline (process gone, permission, etc.) — let the
|
||||
// existing load() path handle it; if the process is dead the load
|
||||
// will fail with a clear error.
|
||||
return true;
|
||||
}
|
||||
|
||||
if (cmdline.includes('--type=')) {
|
||||
const type = /--type=([^\0\s]+)/.exec(cmdline)?.[1] ?? 'unknown';
|
||||
log.info('auto-load skip: PID=%d is an Electron child process (--type=%s)', pid, type);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export type { HookProcessBaseInfo } from './injector';
|
||||
export type { HookProcessAccount, HookProcessInfo, HookProcessStatus } from './types';
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { PacketSender, SendPacketResult } from '@snowluma/common/packet-sender';
|
||||
import { packetTimeoutMs } from './packet-timeout';
|
||||
import {
|
||||
HookPipeRequestError,
|
||||
PIPE_STATUS_CONNECTION_UNAVAILABLE,
|
||||
QqHookClient,
|
||||
} from './qq-hook-client';
|
||||
|
||||
export type OutboundHealthListener = (healthy: boolean) => void;
|
||||
|
||||
export class HookPacketClient implements PacketSender {
|
||||
private outboundHealthy = true;
|
||||
|
||||
constructor(
|
||||
private readonly client: QqHookClient,
|
||||
private readonly onOutboundHealthChanged?: OutboundHealthListener,
|
||||
) {}
|
||||
|
||||
async sendPacket(
|
||||
serviceCmd: string,
|
||||
body: Buffer,
|
||||
timeoutMs = packetTimeoutMs(),
|
||||
): Promise<SendPacketResult> {
|
||||
if (!this.client.isLoggedIn) {
|
||||
return {
|
||||
success: false,
|
||||
gotResponse: false,
|
||||
errorCode: -1,
|
||||
errorMessage: 'qq_hook client is not logged in',
|
||||
responseData: null,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const reply = await this.client.send(serviceCmd, body, {
|
||||
wantReply: true,
|
||||
replyTimeoutMs: timeoutMs,
|
||||
});
|
||||
this.setOutboundHealthy(true);
|
||||
return {
|
||||
success: reply.error === 0,
|
||||
gotResponse: true,
|
||||
errorCode: reply.error,
|
||||
errorMessage: reply.message,
|
||||
responseData: reply.body,
|
||||
};
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof HookPipeRequestError &&
|
||||
error.status === PIPE_STATUS_CONNECTION_UNAVAILABLE
|
||||
) {
|
||||
this.setOutboundHealthy(false);
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
gotResponse: false,
|
||||
errorCode: -1,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
responseData: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private setOutboundHealthy(healthy: boolean): void {
|
||||
if (this.outboundHealthy === healthy) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.outboundHealthy = healthy;
|
||||
this.onOutboundHealthChanged?.(healthy);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { errorMessage } from '@snowluma/common/errors';
|
||||
import { readFileSync } from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
export interface HookRuntimeDirDeps {
|
||||
platform?: NodeJS.Platform;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
ownUid?: number;
|
||||
readText?: (file: string) => string;
|
||||
}
|
||||
|
||||
function configuredDirectory(value: string | undefined, variable: string): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!path.isAbsolute(value)) {
|
||||
throw new Error(`${variable} must be an absolute path`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseProcessEnvironment(raw: string): NodeJS.ProcessEnv {
|
||||
const result: NodeJS.ProcessEnv = {};
|
||||
|
||||
for (const entry of raw.split('\0')) {
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const separator = entry.indexOf('=');
|
||||
|
||||
if (separator <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
result[entry.slice(0, separator)] = entry.slice(separator + 1);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseRealUid(status: string): number | null {
|
||||
const match = /^Uid:\s+(\d+)/m.exec(status);
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const uid = Number(match[1]);
|
||||
return Number.isSafeInteger(uid) && uid >= 0 ? uid : null;
|
||||
}
|
||||
|
||||
function isProcessGoneError(error: unknown): boolean {
|
||||
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
||||
return code === 'ENOENT' || code === 'ESRCH';
|
||||
}
|
||||
|
||||
function ownRuntimeDir(platform: NodeJS.Platform, env: NodeJS.ProcessEnv, ownUid: number): string {
|
||||
const explicit = configuredDirectory(
|
||||
env.SNOWLUMA_HOOK_RUNTIME_DIR,
|
||||
'SNOWLUMA_HOOK_RUNTIME_DIR',
|
||||
);
|
||||
|
||||
if (explicit) {
|
||||
return explicit;
|
||||
}
|
||||
|
||||
const xdg = configuredDirectory(env.XDG_RUNTIME_DIR, 'XDG_RUNTIME_DIR');
|
||||
|
||||
if (xdg) {
|
||||
return xdg;
|
||||
}
|
||||
|
||||
if (platform === 'darwin') {
|
||||
const tmpdir = configuredDirectory(env.TMPDIR, 'TMPDIR');
|
||||
|
||||
if (tmpdir) {
|
||||
return path.join(tmpdir, 'snowluma-hook');
|
||||
}
|
||||
}
|
||||
|
||||
return `/tmp/snowluma-${ownUid}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the socket directory for the target QQ process. The supervisor may
|
||||
* run under a different user, so its own runtime directory is not authoritative.
|
||||
*/
|
||||
export function resolveHookRuntimeDir(targetPid?: number, deps: HookRuntimeDirDeps = {}): string {
|
||||
const platform = deps.platform ?? process.platform;
|
||||
const env = deps.env ?? process.env;
|
||||
const ownUid =
|
||||
deps.ownUid ??
|
||||
(typeof process.geteuid === 'function' ? process.geteuid() : os.userInfo().uid);
|
||||
const readText = deps.readText ?? ((file: string) => readFileSync(file, 'utf8'));
|
||||
|
||||
if (platform !== 'linux' || !Number.isSafeInteger(targetPid) || targetPid! <= 0) {
|
||||
return ownRuntimeDir(platform, env, ownUid);
|
||||
}
|
||||
|
||||
let environError: unknown;
|
||||
|
||||
try {
|
||||
const targetEnv = parseProcessEnvironment(readText(`/proc/${targetPid}/environ`));
|
||||
const explicit = configuredDirectory(
|
||||
targetEnv.SNOWLUMA_HOOK_RUNTIME_DIR,
|
||||
`QQ process ${targetPid} SNOWLUMA_HOOK_RUNTIME_DIR`,
|
||||
);
|
||||
|
||||
if (explicit) {
|
||||
return explicit;
|
||||
}
|
||||
|
||||
const xdg = configuredDirectory(
|
||||
targetEnv.XDG_RUNTIME_DIR,
|
||||
`QQ process ${targetPid} XDG_RUNTIME_DIR`,
|
||||
);
|
||||
|
||||
if (xdg) {
|
||||
return xdg;
|
||||
}
|
||||
|
||||
environError = new Error('runtime directory is not present');
|
||||
} catch (error) {
|
||||
// An invalid explicit path is configuration corruption and must remain
|
||||
// visible. Permission/race failures are expected when /proc disappears;
|
||||
// the status-file fallback below can still recover the target uid.
|
||||
if (error instanceof Error && error.message.includes('must be an absolute path')) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
environError = error;
|
||||
}
|
||||
|
||||
let statusError: unknown;
|
||||
|
||||
try {
|
||||
const uid = parseRealUid(readText(`/proc/${targetPid}/status`));
|
||||
|
||||
if (uid !== null) {
|
||||
return `/tmp/snowluma-${uid}`;
|
||||
}
|
||||
|
||||
statusError = new Error('real uid is not available');
|
||||
} catch (error) {
|
||||
statusError = error;
|
||||
}
|
||||
|
||||
if (
|
||||
isProcessGoneError(statusError) &&
|
||||
(isProcessGoneError(environError) ||
|
||||
(environError instanceof Error &&
|
||||
environError.message === 'runtime directory is not present'))
|
||||
) {
|
||||
return ownRuntimeDir(platform, env, ownUid);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`cannot resolve hook runtime directory for PID ${targetPid}: ` +
|
||||
`environment=${errorMessage(environError)}; status=${errorMessage(statusError)}`,
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,37 @@
|
||||
import type { HookProcessStatus } from './types';
|
||||
|
||||
/**
|
||||
* The flags that fully determine a HookSession's *settled* status.
|
||||
* `wasLoggedIn` carries the one bit of history the derivation needs:
|
||||
* on ANY injected-but-not-connected path (connect failure, pipe close,
|
||||
* refresh-while-down), the session reads `disconnected` only if it had
|
||||
* reached login, otherwise it's still `connecting`. Capture it from
|
||||
* `loggedIn` *before* tearing the client down, since teardown clears it.
|
||||
*/
|
||||
export interface HookFlags {
|
||||
injected: boolean;
|
||||
connected: boolean;
|
||||
loggedIn: boolean;
|
||||
wasLoggedIn: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for the five *settled* HookProcessStatus values
|
||||
* — `available` / `connecting` / `loaded` / `online` / `disconnected`.
|
||||
*
|
||||
* Pure: status is a function of the flags alone. The two *out-of-band*
|
||||
* states `loading` (injection in flight) and `error` (carries a message)
|
||||
* are set explicitly by HookSession and are deliberately NOT derivable
|
||||
* here.
|
||||
*/
|
||||
export function statusFor(flags: HookFlags): HookProcessStatus {
|
||||
if (!flags.injected) {
|
||||
return 'available';
|
||||
}
|
||||
|
||||
if (flags.connected) {
|
||||
return flags.loggedIn ? 'online' : 'loaded';
|
||||
}
|
||||
|
||||
return flags.wasLoggedIn ? 'disconnected' : 'connecting';
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export {
|
||||
HookManager,
|
||||
type BridgeManagerSink,
|
||||
type HookManagerDeps,
|
||||
type HookProcessAccount,
|
||||
type HookProcessBaseInfo,
|
||||
type HookProcessInfo,
|
||||
type HookProcessStatus,
|
||||
} from './hook-manager';
|
||||
export { type AccountLoginState, type LocalAccount } from './qq-account';
|
||||
export {
|
||||
getRuntimeIntegrity,
|
||||
hasLowProcessIntegrity,
|
||||
type RuntimeIntegrity,
|
||||
} from './runtime-integrity';
|
||||
@@ -0,0 +1,137 @@
|
||||
import { errorMessage } from '@snowluma/common/errors';
|
||||
import { resolveRuntimeNativePath } from '@snowluma/common/native-path';
|
||||
|
||||
import { listSnowlumaPipePidsSync } from './qq-hook-client';
|
||||
|
||||
export interface ManualMapHandle {
|
||||
base: bigint;
|
||||
entry: bigint;
|
||||
exceptionTable: bigint;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface NativeHookAddon {
|
||||
getRuntimeIntegrityAsync(): Promise<unknown>;
|
||||
getAllMainProcessAsync(): Promise<number[]>;
|
||||
getFileHoldersAsync(path: string): Promise<{ pid: number; name: string }[]>;
|
||||
loadModuleManualAsync(pid: number, dylibPath: string): Promise<ManualMapHandle>;
|
||||
unloadModuleManualAsync(pid: number, handle: ManualMapHandle): Promise<void>;
|
||||
}
|
||||
|
||||
export interface HookProcessBaseInfo {
|
||||
pid: number;
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface HookInjectResult {
|
||||
method: 'loadModuleManual';
|
||||
handle: ManualMapHandle;
|
||||
}
|
||||
|
||||
let nativeAddon: NativeHookAddon | null = null;
|
||||
let nativeLoadError: string | null = null;
|
||||
|
||||
function loadNativeAddon(addonPath: string): NativeHookAddon {
|
||||
const mod = { exports: {} as Record<string, unknown> };
|
||||
process.dlopen(mod, addonPath);
|
||||
return mod.exports as unknown as NativeHookAddon;
|
||||
}
|
||||
|
||||
type HookBinaryExt = 'node' | 'dll' | 'so';
|
||||
|
||||
function platformInjectableExt(): 'dll' | 'so' {
|
||||
return process.platform === 'win32' ? 'dll' : 'so';
|
||||
}
|
||||
|
||||
function defaultProcessName(): string {
|
||||
return process.platform === 'win32' ? 'QQ.exe' : 'qq';
|
||||
}
|
||||
|
||||
export function resolveHookNativePath(ext: HookBinaryExt): string | null {
|
||||
return resolveRuntimeNativePath(`snowluma-${process.platform}-${process.arch}.${ext}`);
|
||||
}
|
||||
|
||||
export function getNativeHookAddon(): NativeHookAddon | null {
|
||||
if (nativeAddon) {
|
||||
return nativeAddon;
|
||||
}
|
||||
|
||||
const addonPath = resolveHookNativePath('node');
|
||||
|
||||
if (!addonPath) {
|
||||
nativeLoadError = `No hook native addon found for ${process.platform}-${process.arch}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
nativeAddon = loadNativeAddon(addonPath);
|
||||
nativeLoadError = null;
|
||||
return nativeAddon;
|
||||
} catch (error) {
|
||||
nativeLoadError = errorMessage(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getNativeHookLoadError(): string | null {
|
||||
return nativeLoadError;
|
||||
}
|
||||
|
||||
export async function listHookProcesses(): Promise<HookProcessBaseInfo[]> {
|
||||
// macOS: no native enumerate-QQ addon. The DYLD_INSERT injection model
|
||||
// means QQ ran with our dylib already mapped, so the dylib's listener
|
||||
// socket IS the discovery signal. Treat every `mojo.<pid>.control.sock`
|
||||
// in the runtime dir as a known process (the watcher's subsequent
|
||||
// connectable-probe gates the pipe-up emit, so a stale dangling socket
|
||||
// here doesn't drive a false connect).
|
||||
if (process.platform === 'darwin') {
|
||||
return [...listSnowlumaPipePidsSync()]
|
||||
.sort((a, b) => a - b)
|
||||
.map((pid) => ({ pid, name: defaultProcessName(), path: '' }));
|
||||
}
|
||||
|
||||
const addon = getNativeHookAddon();
|
||||
|
||||
if (!addon) {
|
||||
throw new Error(getNativeHookLoadError() ?? 'hook native addon is not available');
|
||||
}
|
||||
|
||||
if (typeof addon.getAllMainProcessAsync !== 'function') {
|
||||
throw new Error(
|
||||
'Hook native addon lacks getAllMainProcessAsync; update the native artifacts',
|
||||
);
|
||||
}
|
||||
|
||||
return [...new Set(await addon.getAllMainProcessAsync())]
|
||||
.filter((pid) => Number.isInteger(pid) && pid > 0)
|
||||
.sort((a, b) => a - b)
|
||||
.map((pid) => ({ pid, name: defaultProcessName(), path: '' }));
|
||||
}
|
||||
|
||||
export async function injectHookProcess(pid: number): Promise<HookInjectResult> {
|
||||
const addon = getNativeHookAddon();
|
||||
|
||||
if (!addon) {
|
||||
throw new Error(getNativeHookLoadError() ?? 'hook native addon is not available');
|
||||
}
|
||||
|
||||
const injectableExt = platformInjectableExt();
|
||||
const dllPath = resolveHookNativePath(injectableExt);
|
||||
|
||||
if (!dllPath) {
|
||||
throw new Error(`No hook ${injectableExt} found for ${process.platform}-${process.arch}`);
|
||||
}
|
||||
|
||||
return { method: 'loadModuleManual', handle: await addon.loadModuleManualAsync(pid, dllPath) };
|
||||
}
|
||||
|
||||
export async function unloadHookProcess(pid: number, handle: ManualMapHandle): Promise<void> {
|
||||
const addon = getNativeHookAddon();
|
||||
|
||||
if (!addon) {
|
||||
throw new Error(getNativeHookLoadError() ?? 'hook native addon is not available');
|
||||
}
|
||||
|
||||
await addon.unloadModuleManualAsync(pid, handle);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { positiveIntEnv } from '@snowluma/common/env';
|
||||
|
||||
/** Default wait for a QQ reply when the caller does not pass one. */
|
||||
export const DEFAULT_PACKET_TIMEOUT_MS = 15_000;
|
||||
|
||||
export function packetTimeoutMs(env?: NodeJS.ProcessEnv): number {
|
||||
return positiveIntEnv('SNOWLUMA_PACKET_TIMEOUT_MS', DEFAULT_PACKET_TIMEOUT_MS, { env });
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { errorMessage } from '@snowluma/common/errors';
|
||||
import { createLogger, type Logger } from '@snowluma/common/logger';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { setImmediate as nextTurn } from 'node:timers/promises';
|
||||
import type { HookProcessBaseInfo } from './injector';
|
||||
|
||||
const DEFAULT_INTERVAL_MS = 1500;
|
||||
const MIN_INTERVAL_MS = 250;
|
||||
|
||||
export type PipeWatcherDeps = {
|
||||
/** Failed enumeration returns null and preserves the last known snapshot. */
|
||||
listProcesses: () => Promise<HookProcessBaseInfo[] | null>;
|
||||
/** Linux uses this snapshot to locate each target user's runtime directory. */
|
||||
listLivePipes: (processes: readonly HookProcessBaseInfo[]) => Promise<Set<number>>;
|
||||
intervalMs?: number;
|
||||
log?: Logger;
|
||||
};
|
||||
|
||||
type PipeEvent =
|
||||
| ['process-discovered', HookProcessBaseInfo]
|
||||
| ['pipe-up' | 'pipe-down' | 'process-gone', number];
|
||||
|
||||
/** Publishes process and pipe snapshots, then emits their ordered differences. */
|
||||
export class PipeWatcher extends EventEmitter {
|
||||
private readonly listProcesses: PipeWatcherDeps['listProcesses'];
|
||||
private readonly listLivePipes: PipeWatcherDeps['listLivePipes'];
|
||||
private readonly intervalMs: number;
|
||||
private readonly log: Logger;
|
||||
private livePipes = new Set<number>();
|
||||
private knownPids = new Set<number>();
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private tickTask: Promise<void> | null = null;
|
||||
private pendingTick = false;
|
||||
private startPromise: Promise<void> | null = null;
|
||||
private stopped = false;
|
||||
|
||||
constructor(deps: PipeWatcherDeps) {
|
||||
super();
|
||||
this.listProcesses = deps.listProcesses;
|
||||
this.listLivePipes = deps.listLivePipes;
|
||||
this.intervalMs = Math.max(MIN_INTERVAL_MS, deps.intervalMs ?? DEFAULT_INTERVAL_MS);
|
||||
this.log = deps.log ?? createLogger('PipeWatcher');
|
||||
}
|
||||
|
||||
isPipeLive(pid: number): boolean {
|
||||
return this.livePipes.has(pid);
|
||||
}
|
||||
|
||||
isProcessAlive(pid: number): boolean {
|
||||
return this.knownPids.has(pid);
|
||||
}
|
||||
|
||||
/** Read the initial snapshot and start polling. Repeated calls share startup. */
|
||||
start(): Promise<void> {
|
||||
return (this.startPromise ??= this.tickNow().finally(() => {
|
||||
this.scheduleNext(this.intervalMs);
|
||||
}));
|
||||
}
|
||||
|
||||
/** Coalesce wake requests into one scan after the current read. */
|
||||
wake(): void {
|
||||
if (this.stopped) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.tickTask) {
|
||||
this.pendingTick = true;
|
||||
} else {
|
||||
this.scheduleNext(0);
|
||||
}
|
||||
}
|
||||
|
||||
/** Wait for a fresh snapshot, including a scan queued behind an active read. */
|
||||
tickNow(): Promise<void> {
|
||||
if (this.stopped) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
if (this.tickTask) {
|
||||
this.pendingTick = true;
|
||||
return this.tickTask;
|
||||
}
|
||||
|
||||
this.tickTask = (async () => {
|
||||
do {
|
||||
this.pendingTick = false;
|
||||
await this.readSnapshot();
|
||||
|
||||
if (this.pendingTick && !this.stopped) {
|
||||
await nextTurn();
|
||||
}
|
||||
} while (this.pendingTick && !this.stopped);
|
||||
})().finally(() => {
|
||||
this.tickTask = null;
|
||||
});
|
||||
return this.tickTask;
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopped = true;
|
||||
this.pendingTick = false;
|
||||
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.stop();
|
||||
this.removeAllListeners();
|
||||
}
|
||||
|
||||
private scheduleNext(delayMs: number): void {
|
||||
if (this.stopped) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null;
|
||||
void this.tickAndReschedule();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
private async tickAndReschedule(): Promise<void> {
|
||||
try {
|
||||
await this.tickNow();
|
||||
} catch (error) {
|
||||
this.log.warn('poll failed: %s', errorMessage(error));
|
||||
} finally {
|
||||
this.scheduleNext(this.intervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
private async readSnapshot(): Promise<void> {
|
||||
let processes: HookProcessBaseInfo[] | null;
|
||||
|
||||
try {
|
||||
processes = await this.listProcesses();
|
||||
} catch (error) {
|
||||
this.log.warn('listProcesses failed: %s', errorMessage(error));
|
||||
processes = null;
|
||||
}
|
||||
|
||||
if (this.stopped) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (processes === null) {
|
||||
// Missing data must not disconnect healthy sessions. Reconnect retries
|
||||
// still run against the last known snapshot (issue #158).
|
||||
this.emit('tick');
|
||||
return;
|
||||
}
|
||||
|
||||
let livePipes: Set<number>;
|
||||
|
||||
try {
|
||||
livePipes = await this.listLivePipes(processes);
|
||||
} catch (error) {
|
||||
this.log.warn('listLivePipes failed: %s', errorMessage(error));
|
||||
livePipes = this.livePipes;
|
||||
}
|
||||
|
||||
if (this.stopped) {
|
||||
return;
|
||||
}
|
||||
|
||||
const knownPids = new Set(processes.map(({ pid }) => pid));
|
||||
const currentPipes = new Set([...livePipes].filter((pid) => knownPids.has(pid)));
|
||||
const events: PipeEvent[] = [];
|
||||
|
||||
for (const process of processes) {
|
||||
if (!this.knownPids.has(process.pid)) {
|
||||
events.push(['process-discovered', process]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const pid of currentPipes) {
|
||||
if (!this.livePipes.has(pid)) {
|
||||
events.push(['pipe-up', pid]);
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnect pipes before disposing their departed processes.
|
||||
for (const pid of this.livePipes) {
|
||||
if (!currentPipes.has(pid)) {
|
||||
events.push(['pipe-down', pid]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const pid of this.knownPids) {
|
||||
if (!knownPids.has(pid)) {
|
||||
events.push(['process-gone', pid]);
|
||||
}
|
||||
}
|
||||
|
||||
this.knownPids = knownPids;
|
||||
this.livePipes = currentPipes;
|
||||
|
||||
for (const [event, payload] of events) {
|
||||
if (this.stopped) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.emit(event, payload);
|
||||
}
|
||||
|
||||
if (!this.stopped) {
|
||||
this.emit('tick');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { errorMessage } from '@snowluma/common/errors';
|
||||
import { createLogger, type Logger } from '@snowluma/common/logger';
|
||||
import type { HookProcessBaseInfo } from './injector';
|
||||
|
||||
export interface ProcessEnumerator {
|
||||
/** null means unknown: keep the previous process set. */
|
||||
enumerate(): Promise<HookProcessBaseInfo[] | null>;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
interface ProcessEnumeratorDeps {
|
||||
listProcesses: () => Promise<HookProcessBaseInfo[]>;
|
||||
timeoutMs?: number;
|
||||
log?: Logger;
|
||||
}
|
||||
|
||||
export function createProcessEnumerator(deps: ProcessEnumeratorDeps): ProcessEnumerator {
|
||||
const log = deps.log ?? createLogger('ProcessEnumerator');
|
||||
const timeoutMs = deps.timeoutMs ?? 4000;
|
||||
let disposed = false;
|
||||
let pending: {
|
||||
promise: Promise<HookProcessBaseInfo[] | null>;
|
||||
settle: (result: HookProcessBaseInfo[] | null) => void;
|
||||
} | null = null;
|
||||
|
||||
return {
|
||||
enumerate() {
|
||||
if (disposed) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
if (pending) {
|
||||
return pending.promise;
|
||||
}
|
||||
|
||||
let resolve!: (result: HookProcessBaseInfo[] | null) => void;
|
||||
const promise = new Promise<HookProcessBaseInfo[] | null>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
|
||||
const settle = (result: HookProcessBaseInfo[] | null) => {
|
||||
clearTimeout(timer);
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
log.warn(
|
||||
'process enumeration timed out after %dms; keeping prior process set',
|
||||
timeoutMs,
|
||||
);
|
||||
settle(null);
|
||||
}, timeoutMs);
|
||||
pending = { promise, settle };
|
||||
|
||||
// A timeout ends the wait, not the native call. Keep its slot until it
|
||||
// finishes so subsequent polls cannot accumulate blocked native work.
|
||||
void Promise.resolve()
|
||||
.then(() => (disposed ? null : deps.listProcesses()))
|
||||
.then(
|
||||
(processes) => {
|
||||
pending = null;
|
||||
settle(disposed ? null : processes);
|
||||
},
|
||||
(error) => {
|
||||
pending = null;
|
||||
log.warn('process enumeration failed: %s', errorMessage(error));
|
||||
settle(null);
|
||||
},
|
||||
);
|
||||
return promise;
|
||||
},
|
||||
dispose() {
|
||||
disposed = true;
|
||||
pending?.settle(null);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import { getNativeHookAddon } from '../injector';
|
||||
|
||||
export interface DatabaseHolder {
|
||||
pid: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export async function getDatabaseHolders(dbPath: string): Promise<DatabaseHolder[]> {
|
||||
if (process.platform === 'win32') {
|
||||
return getWindowsHolders(dbPath);
|
||||
}
|
||||
|
||||
return process.platform === 'linux' ? getLinuxHolders(dbPath) : [];
|
||||
}
|
||||
|
||||
async function getWindowsHolders(dbPath: string): Promise<DatabaseHolder[]> {
|
||||
const addon = getNativeHookAddon();
|
||||
|
||||
if (!addon?.getFileHoldersAsync) {
|
||||
throw new Error('Windows account detection requires a hook addon with getFileHoldersAsync');
|
||||
}
|
||||
|
||||
const holders = await addon.getFileHoldersAsync(dbPath);
|
||||
|
||||
if (
|
||||
!Array.isArray(holders) ||
|
||||
holders.some(
|
||||
(holder) =>
|
||||
!holder ||
|
||||
!Number.isInteger(holder.pid) ||
|
||||
holder.pid <= 0 ||
|
||||
typeof holder.name !== 'string',
|
||||
)
|
||||
) {
|
||||
throw new Error('Invalid database holders returned by the hook addon');
|
||||
}
|
||||
|
||||
return holders;
|
||||
}
|
||||
|
||||
export function isQqProcessName(name: string): boolean {
|
||||
return (
|
||||
name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\.exe$/, '') === 'qq'
|
||||
);
|
||||
}
|
||||
|
||||
/** Match POSIX locks by device and inode; the table does not contain file paths. */
|
||||
async function getLinuxHolders(dbPath: string): Promise<DatabaseHolder[]> {
|
||||
const stat = await fs.stat(dbPath).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
|
||||
if (!stat) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const key = deviceInodeKey(stat.dev, stat.ino);
|
||||
const table = await fs.readFile('/proc/locks', 'utf8');
|
||||
const holders: DatabaseHolder[] = [];
|
||||
const seen = new Set<number>();
|
||||
|
||||
for (const line of table.split('\n')) {
|
||||
// `<id>: <type> <advisory|mandatory> <read|write> <pid> <dev>:<inode> <start> <end>`
|
||||
const fields = line.trim().split(/\s+/);
|
||||
|
||||
if (fields.length < 8 || fields[5] !== key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pid = Number(fields[4]);
|
||||
|
||||
if (!Number.isInteger(pid) || pid <= 0 || seen.has(pid)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(pid);
|
||||
holders.push({ pid, name: await readProcessName(pid) });
|
||||
}
|
||||
|
||||
return holders;
|
||||
}
|
||||
|
||||
function deviceInodeKey(device: number, inode: number): string {
|
||||
const major = (device >> 8) & 0xfff;
|
||||
const minor = (device & 0xff) | ((device >> 12) & 0xfff00);
|
||||
return `${major.toString(16).padStart(2, '0')}:${minor.toString(16).padStart(2, '0')}:${inode}`;
|
||||
}
|
||||
|
||||
async function readProcessName(pid: number): Promise<string> {
|
||||
try {
|
||||
return (await fs.readFile(`/proc/${pid}/comm`, 'utf8')).trim();
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return '';
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createLogger } from '@snowluma/common/logger';
|
||||
import { isQqProcessName, getDatabaseHolders } from './database-holders';
|
||||
import { readLocalAccounts, type LocalAccount } from './login-db';
|
||||
import { loginDbPaths, ntMsgDbPath, qqDataRoots, qqDataRootsForPid } from './paths';
|
||||
|
||||
const log = createLogger('QqAccount');
|
||||
const reportedFailures = new Map<string, string>();
|
||||
|
||||
export interface AccountLoginState extends LocalAccount {
|
||||
pid: number | null;
|
||||
}
|
||||
|
||||
export async function detectAccountLogins(
|
||||
roots: readonly string[] = qqDataRoots(),
|
||||
): Promise<AccountLoginState[]> {
|
||||
const states: AccountLoginState[] = [];
|
||||
|
||||
for (const root of roots) {
|
||||
const report = await readLocalAccounts(root);
|
||||
|
||||
for (const dbPath of loginDbPaths(root)) {
|
||||
const failure = report.failures.find((item) => item.path === dbPath);
|
||||
|
||||
if (!failure) {
|
||||
reportedFailures.delete(dbPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
const detail =
|
||||
failure.error instanceof Error
|
||||
? failure.error.message
|
||||
: String(failure.error ?? '');
|
||||
const reason = detail ? failure.reason + ': ' + detail : failure.reason;
|
||||
|
||||
if (reportedFailures.get(dbPath) !== reason) {
|
||||
reportedFailures.set(dbPath, reason);
|
||||
log.warn('login.db unreadable: path=%s reason=%s', dbPath, reason);
|
||||
}
|
||||
}
|
||||
|
||||
for (const account of report.accounts) {
|
||||
const holder = (await getDatabaseHolders(ntMsgDbPath(root, account))).find(
|
||||
(candidate) => isQqProcessName(candidate.name),
|
||||
);
|
||||
states.push({ ...account, pid: holder?.pid ?? null });
|
||||
}
|
||||
}
|
||||
|
||||
return states;
|
||||
}
|
||||
|
||||
export async function detectAccountLoginsForPids(
|
||||
pids: readonly number[],
|
||||
): Promise<AccountLoginState[]> {
|
||||
const roots = [...new Set(pids.flatMap((pid) => qqDataRootsForPid(pid)))];
|
||||
return detectAccountLogins(roots);
|
||||
}
|
||||
|
||||
export async function detectUinForPid(pid: number): Promise<string | null> {
|
||||
const accounts = await detectAccountLoginsForPids([pid]);
|
||||
return accounts.find((account) => account.pid === pid)?.uin ?? null;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export { isQqProcessName, getDatabaseHolders, type DatabaseHolder } from './database-holders';
|
||||
export {
|
||||
detectAccountLogins,
|
||||
detectAccountLoginsForPids,
|
||||
detectUinForPid,
|
||||
type AccountLoginState,
|
||||
} from './detect';
|
||||
export {
|
||||
readLocalAccounts,
|
||||
type LocalAccount,
|
||||
type LocalAccountReport,
|
||||
type LoginDbFailure,
|
||||
} from './login-db';
|
||||
export { accountDirName, loginDbPaths, ntMsgDbPath, qqDataRoots, qqDataRootsForPid } from './paths';
|
||||
export { watchAccountChangesForPids } from './watch';
|
||||
export {
|
||||
decryptDatabase,
|
||||
detectAlgorithm,
|
||||
type DecryptedDatabase,
|
||||
type KdfHmacAlgorithm,
|
||||
type PageHmacAlgorithm,
|
||||
type SqlcipherAlgorithm,
|
||||
} from './sqlcipher';
|
||||
@@ -0,0 +1,199 @@
|
||||
import { isRealUin } from '@snowluma/common/uin';
|
||||
import { SqliteDatabase } from '@snowluma/sqlite';
|
||||
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { loginDbPaths } from './paths';
|
||||
import { decryptDatabase, detectAlgorithm } from './sqlcipher';
|
||||
|
||||
const LOGIN_DB_PASSPHRASE = 'BD156D6710D54D8782F4';
|
||||
const COLUMN_UIN = '1000';
|
||||
const COLUMN_UID = '1001';
|
||||
const COLUMN_NICKNAME = '1007';
|
||||
|
||||
export interface LocalAccount {
|
||||
uin: string;
|
||||
uid: string;
|
||||
nickname: string;
|
||||
}
|
||||
|
||||
export type LoginDbFailure = 'read-failed' | 'undecryptable' | 'query-failed';
|
||||
|
||||
type LoginDbResult = { accounts: LocalAccount[] } | { failure: LoginDbFailure; error?: unknown };
|
||||
|
||||
export interface LocalAccountReport {
|
||||
accounts: LocalAccount[];
|
||||
failures: Array<{ path: string; reason: LoginDbFailure; error?: unknown }>;
|
||||
}
|
||||
|
||||
// Share concurrent reads and invalidate when either the database or its WAL changes.
|
||||
const accountCache = new Map<string, { stamp: string; result: Promise<LoginDbResult> }>();
|
||||
|
||||
export async function readLocalAccounts(root: string): Promise<LocalAccountReport> {
|
||||
const merged = new Map<string, LocalAccount>();
|
||||
const failures: LocalAccountReport['failures'] = [];
|
||||
|
||||
for (const dbPath of loginDbPaths(root)) {
|
||||
const result = await readLoginDb(dbPath);
|
||||
|
||||
if ('failure' in result) {
|
||||
failures.push({ path: dbPath, reason: result.failure, error: result.error });
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const account of result.accounts) {
|
||||
if (!merged.has(account.uin)) {
|
||||
merged.set(account.uin, account);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { accounts: [...merged.values()], failures };
|
||||
}
|
||||
|
||||
async function readLoginDb(dbPath: string): Promise<LoginDbResult> {
|
||||
let stamp: string;
|
||||
|
||||
try {
|
||||
const files = await Promise.all([statIfPresent(dbPath), statIfPresent(dbPath + '-wal')]);
|
||||
stamp = files.map((info) => (info ? info.mtimeMs + ':' + info.size : '-')).join('|');
|
||||
} catch (error) {
|
||||
accountCache.delete(dbPath);
|
||||
return { failure: 'read-failed', error };
|
||||
}
|
||||
|
||||
const cached = accountCache.get(dbPath);
|
||||
|
||||
if (cached?.stamp === stamp) {
|
||||
return cached.result;
|
||||
}
|
||||
|
||||
const entry = { stamp, result: decryptAndQuery(dbPath) };
|
||||
accountCache.set(dbPath, entry);
|
||||
const result = await entry.result;
|
||||
|
||||
if ('failure' in result && accountCache.get(dbPath) === entry) {
|
||||
accountCache.delete(dbPath);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function decryptAndQuery(dbPath: string): Promise<LoginDbResult> {
|
||||
let image: Buffer;
|
||||
let wal: Buffer | null;
|
||||
|
||||
try {
|
||||
const contents = await readIfPresent(dbPath);
|
||||
|
||||
if (!contents) {
|
||||
return { accounts: [] };
|
||||
}
|
||||
|
||||
image = contents;
|
||||
wal = await readIfPresent(dbPath + '-wal');
|
||||
} catch (error) {
|
||||
return { failure: 'read-failed', error };
|
||||
}
|
||||
|
||||
const algorithm = detectAlgorithm(image, LOGIN_DB_PASSPHRASE);
|
||||
|
||||
if (!algorithm) {
|
||||
return { failure: 'undecryptable' };
|
||||
}
|
||||
|
||||
const decrypted = decryptDatabase(image, wal, LOGIN_DB_PASSPHRASE, algorithm);
|
||||
|
||||
if (!decrypted) {
|
||||
return { failure: 'undecryptable' };
|
||||
}
|
||||
|
||||
try {
|
||||
return { accounts: await queryLoginTable(decrypted.image) };
|
||||
} catch (error) {
|
||||
return { failure: 'query-failed', error };
|
||||
}
|
||||
}
|
||||
|
||||
// SQLite opens a private temporary image; QQ's database and sidecars stay read-only.
|
||||
async function queryLoginTable(image: Buffer): Promise<LocalAccount[]> {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), 'snowluma-qq-login-'));
|
||||
const dbPath = path.join(directory, 'login.db');
|
||||
|
||||
try {
|
||||
await writeFile(dbPath, image, { mode: 0o600 });
|
||||
const db = new SqliteDatabase(dbPath, { readOnly: true });
|
||||
|
||||
try {
|
||||
const rows = await db
|
||||
.prepare(
|
||||
`SELECT [${COLUMN_UIN}], [${COLUMN_UID}], [${COLUMN_NICKNAME}] FROM login_table`,
|
||||
)
|
||||
.all();
|
||||
const accounts: LocalAccount[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const account = {
|
||||
uin: asText(row[COLUMN_UIN]),
|
||||
uid: asText(row[COLUMN_UID]),
|
||||
nickname: asText(row[COLUMN_NICKNAME]),
|
||||
};
|
||||
|
||||
if (isRealUin(account.uin) && account.uid.length > 0) {
|
||||
accounts.push(account);
|
||||
}
|
||||
}
|
||||
|
||||
return accounts;
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function readIfPresent(file: string): Promise<Buffer | null> {
|
||||
try {
|
||||
return await readFile(file);
|
||||
} catch (error) {
|
||||
if (isNotFound(error)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isNotFound(error: unknown): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
|
||||
}
|
||||
|
||||
async function statIfPresent(file: string) {
|
||||
try {
|
||||
return await stat(file);
|
||||
} catch (error) {
|
||||
if (isNotFound(error)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** SQLite values arrive as numbers, strings or blobs depending on the column. */
|
||||
function asText(value: unknown): string {
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'number' || typeof value === 'bigint') {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
if (value instanceof Uint8Array) {
|
||||
return Buffer.from(value).toString('utf8');
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const LOGIN_DB_SEGMENTS = ['nt_db', 'login.db'] as const;
|
||||
|
||||
export function qqDataRoots(): string[] {
|
||||
if (process.platform === 'win32') {
|
||||
return windowsDataRoots();
|
||||
}
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
return linuxDataRoots({ ...process.env, HOME: process.env.HOME || homedir() });
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Each Linux client owns its HOME; an unavailable process has no known root. */
|
||||
export function qqDataRootsForPid(pid: number): string[] {
|
||||
if (process.platform === 'win32') {
|
||||
return windowsDataRoots();
|
||||
}
|
||||
|
||||
if (process.platform !== 'linux') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const environment = readProcessEnvironment(pid);
|
||||
return environment ? linuxDataRoots(environment) : [];
|
||||
}
|
||||
|
||||
function linuxDataRoots(environment: { HOME?: string; XDG_CONFIG_HOME?: string }): string[] {
|
||||
const base =
|
||||
environment.XDG_CONFIG_HOME ||
|
||||
(environment.HOME ? path.join(environment.HOME, '.config') : null);
|
||||
return base ? [path.join(base, 'QQ')] : [];
|
||||
}
|
||||
|
||||
function readProcessEnvironment(pid: number): { HOME?: string; XDG_CONFIG_HOME?: string } | null {
|
||||
let raw: string;
|
||||
|
||||
try {
|
||||
raw = readFileSync(`/proc/${pid}/environ`, 'utf8');
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
const environment: { HOME?: string; XDG_CONFIG_HOME?: string } = {};
|
||||
|
||||
for (const entry of raw.split('\0')) {
|
||||
const separator = entry.indexOf('=');
|
||||
|
||||
if (separator <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = entry.slice(0, separator);
|
||||
|
||||
if (name === 'HOME' || name === 'XDG_CONFIG_HOME') {
|
||||
environment[name] = entry.slice(separator + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return environment;
|
||||
}
|
||||
|
||||
/** Both Linux layouts occur in QQ installations; the primary takes precedence. */
|
||||
export function loginDbPaths(root: string): string[] {
|
||||
if (process.platform === 'win32') {
|
||||
return [path.join(root, 'nt_qq', 'global', ...LOGIN_DB_SEGMENTS)];
|
||||
}
|
||||
|
||||
return [
|
||||
path.join(root, 'global', ...LOGIN_DB_SEGMENTS),
|
||||
path.join(root, 'nt_qq', 'global', ...LOGIN_DB_SEGMENTS),
|
||||
];
|
||||
}
|
||||
|
||||
export function ntMsgDbPath(root: string, account: { uin: string; uid: string }): string {
|
||||
if (process.platform === 'win32') {
|
||||
return path.join(root, account.uin, 'nt_qq', 'nt_db', 'nt_msg.db');
|
||||
}
|
||||
|
||||
return path.join(root, accountDirName(account.uid), 'nt_db', 'nt_msg.db');
|
||||
}
|
||||
|
||||
export function accountDirName(uid: string): string {
|
||||
const digest = createHash('md5').update(uid, 'utf8').digest('hex');
|
||||
return `nt_qq_${createHash('md5').update(`${digest}nt_kernel`, 'utf8').digest('hex')}`;
|
||||
}
|
||||
|
||||
function windowsDataRoots(): string[] {
|
||||
const configured = tencentFilesRootFromUserDataInfo();
|
||||
return [configured ?? path.join(homedir(), 'Documents', 'Tencent Files')];
|
||||
}
|
||||
|
||||
export function tencentFilesRootFromUserDataInfo(home = homedir()): string | null {
|
||||
const ini = path.join(
|
||||
path.dirname(home),
|
||||
'Public',
|
||||
'Documents',
|
||||
'Tencent',
|
||||
'QQ',
|
||||
'UserDataInfo.ini',
|
||||
);
|
||||
let text: string;
|
||||
|
||||
try {
|
||||
text = readFileSync(ini, 'utf8');
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
let inDataSet = false;
|
||||
|
||||
for (const raw of text.split(/\r?\n/)) {
|
||||
const line = raw.trim();
|
||||
|
||||
if (line.startsWith('[') && line.endsWith(']')) {
|
||||
inDataSet = line.toLowerCase() === '[userdataset]';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inDataSet) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const separator = line.indexOf('=');
|
||||
|
||||
if (
|
||||
separator === -1 ||
|
||||
line.slice(0, separator).trim().toLowerCase() !== 'userdatasavepath'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = line.slice(separator + 1).trim();
|
||||
|
||||
if (value.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The ini always holds Windows paths, so resolve them with the win32
|
||||
// flavor wherever this runs.
|
||||
return path.win32.basename(value).toLowerCase() === 'tencent files'
|
||||
? value
|
||||
: path.win32.join(value, 'Tencent Files');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
/**
|
||||
* SQLCipher v4 page decryption for QQ NT's local databases.
|
||||
*
|
||||
* QQ NT stores every database under `nt_db/` as a SQLCipher v4 stream behind a
|
||||
* 1024-byte client header:
|
||||
*
|
||||
* [ 1024-byte client header ][ page 1 ][ page 2 ] ...
|
||||
* ^ KDF salt = first 16 bytes of page 1
|
||||
*
|
||||
* Each page ends with `IV (16 bytes) || page HMAC`, rounded up to the AES block
|
||||
* size; the page size is 4096 on every database QQ writes. Page 1's ciphertext
|
||||
* starts after its 16-byte salt, which replaces the plaintext "SQLite format 3"
|
||||
* magic — so the decrypted body of page 1 starts at page offset 16 and can never
|
||||
* contain that magic. A candidate key is therefore confirmed by recomputing the
|
||||
* page HMAC over the *ciphertext*, which is independent of the AES pass.
|
||||
*
|
||||
* The page-HMAC and KDF-HMAC algorithms are not fixed across client builds, so
|
||||
* they are probed per database (12 combinations) instead of assumed.
|
||||
*/
|
||||
|
||||
import { createDecipheriv, createHmac, pbkdf2Sync } from 'node:crypto';
|
||||
|
||||
/** QQ NT prepends this many bytes before the SQLCipher stream. */
|
||||
export const DATABASE_HEADER_SIZE = 1024;
|
||||
|
||||
const PAGE_SIZE = 4096;
|
||||
const SALT_SIZE = 16;
|
||||
const IV_SIZE = 16;
|
||||
const KEY_SIZE = 32;
|
||||
|
||||
/** SQLCipher v4 derives the AES key with this many PBKDF2 iterations. */
|
||||
const KDF_ITERATIONS = 4000;
|
||||
/** The per-page HMAC key uses a fast derivation over the page salt. */
|
||||
const HMAC_KEY_ITERATIONS = 2;
|
||||
/** SQLCipher's HMAC salt is the page salt XOR this mask. */
|
||||
const HMAC_SALT_MASK = 0x3a;
|
||||
|
||||
const WAL_HEADER_SIZE = 32;
|
||||
const WAL_FRAME_HEADER_SIZE = 24;
|
||||
|
||||
const SQLITE_MAGIC = Buffer.from('SQLite format 3\0', 'latin1');
|
||||
|
||||
export type PageHmacAlgorithm = 'none' | 'sha1' | 'sha256' | 'sha512';
|
||||
export type KdfHmacAlgorithm = 'sha1' | 'sha256' | 'sha512';
|
||||
|
||||
/** One SQLCipher algorithm pair, as probed against a database's page 1. */
|
||||
export interface SqlcipherAlgorithm {
|
||||
pageHmac: PageHmacAlgorithm;
|
||||
kdfHmac: KdfHmacAlgorithm;
|
||||
}
|
||||
|
||||
/** Every pair to probe, in order: `cipher_use_hmac = OFF` first, then each digest. */
|
||||
const PAGE_HMAC_ALGORITHMS: readonly PageHmacAlgorithm[] = ['none', 'sha1', 'sha256', 'sha512'];
|
||||
const KDF_HMAC_ALGORITHMS: readonly KdfHmacAlgorithm[] = ['sha1', 'sha256', 'sha512'];
|
||||
|
||||
const PAGE_HMAC_DIGEST_BYTES: Record<PageHmacAlgorithm, number> = {
|
||||
none: 0,
|
||||
sha1: 20,
|
||||
sha256: 32,
|
||||
sha512: 64,
|
||||
};
|
||||
|
||||
/** A database image decrypted from disk, with its `-wal` frames folded in. */
|
||||
export interface DecryptedDatabase {
|
||||
/** A self-contained plaintext SQLite image (rollback-journal mode). */
|
||||
image: Buffer;
|
||||
/** Frames folded in from the `-wal` sidecar, 0 when there was nothing to merge. */
|
||||
walFrames: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe the algorithm pair of an encrypted database.
|
||||
*
|
||||
* Returns null when the page-1 HMAC matches no combination under `passphrase`,
|
||||
* which means either the passphrase or the file layout is not what we expect.
|
||||
*/
|
||||
export function detectAlgorithm(image: Buffer, passphrase: string): SqlcipherAlgorithm | null {
|
||||
const pageOne = readPageOne(image);
|
||||
|
||||
if (!pageOne) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const salt = pageOne.subarray(0, SALT_SIZE);
|
||||
|
||||
for (const pageHmac of PAGE_HMAC_ALGORITHMS) {
|
||||
for (const kdfHmac of KDF_HMAC_ALGORITHMS) {
|
||||
const algorithm: SqlcipherAlgorithm = { pageHmac, kdfHmac };
|
||||
const key = deriveKey(passphrase, salt, kdfHmac);
|
||||
|
||||
if (verifyPageOne(pageOne, key, algorithm)) {
|
||||
return algorithm;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a whole database and fold in the `-wal` sidecar when there is one.
|
||||
*
|
||||
* QQ keeps a long-lived WAL, so everything committed since the last checkpoint
|
||||
* — including an account that just signed in — lives only in the sidecar.
|
||||
* Returns null when the image cannot be decrypted.
|
||||
*/
|
||||
export function decryptDatabase(
|
||||
image: Buffer,
|
||||
wal: Buffer | null,
|
||||
passphrase: string,
|
||||
algorithm: SqlcipherAlgorithm,
|
||||
): DecryptedDatabase | null {
|
||||
const pageOne = readPageOne(image);
|
||||
|
||||
if (!pageOne) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = deriveKey(passphrase, pageOne.subarray(0, SALT_SIZE), algorithm.kdfHmac);
|
||||
const base = decryptPages(image, key, algorithm);
|
||||
|
||||
if (!base) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const merged = applyWal(base, wal, key, algorithm);
|
||||
normalizeImage(merged.image);
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** Page 1 as stored, or null when the file is too short to hold a header + page 1. */
|
||||
function readPageOne(image: Buffer): Buffer | null {
|
||||
if (image.length < DATABASE_HEADER_SIZE + PAGE_SIZE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return image.subarray(DATABASE_HEADER_SIZE, DATABASE_HEADER_SIZE + PAGE_SIZE);
|
||||
}
|
||||
|
||||
/** Per-page reserve = IV + page HMAC digest, rounded up to the AES block size. */
|
||||
function reserveBytes(algorithm: SqlcipherAlgorithm): number {
|
||||
const digestBytes = PAGE_HMAC_DIGEST_BYTES[algorithm.pageHmac];
|
||||
return Math.ceil((IV_SIZE + digestBytes) / 16) * 16;
|
||||
}
|
||||
|
||||
function deriveKey(passphrase: string, salt: Buffer, kdfHmac: KdfHmacAlgorithm): Buffer {
|
||||
return pbkdf2Sync(passphrase, salt, KDF_ITERATIONS, KEY_SIZE, kdfHmac);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm a candidate key against page 1.
|
||||
*
|
||||
* With the page HMAC enabled the check runs over the stored ciphertext, exactly
|
||||
* like SQLCipher's own verification. With HMAC off there is nothing to verify on
|
||||
* the ciphertext, so page 1 is decrypted and the SQLite header fields that live
|
||||
* past the salt-occupied first 16 bytes are checked instead.
|
||||
*/
|
||||
function verifyPageOne(pageOne: Buffer, key: Buffer, algorithm: SqlcipherAlgorithm): boolean {
|
||||
const reserve = reserveBytes(algorithm);
|
||||
const digestBytes = PAGE_HMAC_DIGEST_BYTES[algorithm.pageHmac];
|
||||
|
||||
if (digestBytes === 0) {
|
||||
const body = decryptPageBody(pageOne, key, SALT_SIZE, reserve);
|
||||
return body !== null && hasSqliteHeaderFields(body);
|
||||
}
|
||||
|
||||
const hmacSalt = Buffer.alloc(SALT_SIZE);
|
||||
|
||||
for (let index = 0; index < SALT_SIZE; index++) {
|
||||
hmacSalt[index] = pageOne[index] ^ HMAC_SALT_MASK;
|
||||
}
|
||||
|
||||
const hmacKey = pbkdf2Sync(key, hmacSalt, HMAC_KEY_ITERATIONS, KEY_SIZE, algorithm.kdfHmac);
|
||||
const bodyEnd = PAGE_SIZE - reserve;
|
||||
const pageNumber = Buffer.alloc(4);
|
||||
pageNumber.writeUInt32LE(1);
|
||||
const input = Buffer.concat([
|
||||
pageOne.subarray(SALT_SIZE, bodyEnd),
|
||||
pageOne.subarray(bodyEnd, bodyEnd + IV_SIZE),
|
||||
pageNumber,
|
||||
]);
|
||||
const computed = createHmac(algorithm.pageHmac, hmacKey).update(input).digest();
|
||||
const stored = pageOne.subarray(bodyEnd + IV_SIZE, bodyEnd + IV_SIZE + digestBytes);
|
||||
return equalsBytes(computed, stored);
|
||||
}
|
||||
|
||||
/** The decrypted page-1 body starts at file offset 16 (the salt replaces 0..16). */
|
||||
function hasSqliteHeaderFields(body: Buffer): boolean {
|
||||
if (body.length < 8) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pageSize = body.readUInt16BE(0);
|
||||
const pageSizeOk = pageSize === 1 || (pageSize >= 512 && (pageSize & (pageSize - 1)) === 0);
|
||||
return pageSizeOk && body[5] === 64 && body[6] === 32 && body[7] === 32;
|
||||
}
|
||||
|
||||
function equalsBytes(left: Buffer, right: Buffer): boolean {
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let diff = 0;
|
||||
|
||||
for (let index = 0; index < left.length; index++) {
|
||||
diff |= left[index]! ^ right[index]!;
|
||||
}
|
||||
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt every page of the main file, in place on a fresh buffer.
|
||||
*
|
||||
* `skip` drops the salt prefix of page 1; the reserve region of each page is
|
||||
* left zeroed, which is how SQLCipher lays the plaintext out.
|
||||
*/
|
||||
function decryptPages(image: Buffer, key: Buffer, algorithm: SqlcipherAlgorithm): Buffer | null {
|
||||
const stream = image.subarray(DATABASE_HEADER_SIZE);
|
||||
const pageCount = Math.floor(stream.length / PAGE_SIZE);
|
||||
|
||||
if (pageCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const reserve = reserveBytes(algorithm);
|
||||
const plain = Buffer.alloc(pageCount * PAGE_SIZE);
|
||||
|
||||
for (let index = 0; index < pageCount; index++) {
|
||||
const pageNumber = index + 1;
|
||||
const page = stream.subarray(index * PAGE_SIZE, (index + 1) * PAGE_SIZE);
|
||||
const body = decryptPageBody(page, key, pageNumber === 1 ? SALT_SIZE : 0, reserve);
|
||||
|
||||
if (body === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
writePlaintextPage(plain, index * PAGE_SIZE, pageNumber, body);
|
||||
}
|
||||
|
||||
return plain;
|
||||
}
|
||||
|
||||
/** Decrypt one page body, or null when the page cannot hold an IV + digest. */
|
||||
function decryptPageBody(page: Buffer, key: Buffer, skip: number, reserve: number): Buffer | null {
|
||||
const cipherLength = PAGE_SIZE - skip - reserve;
|
||||
|
||||
if (cipherLength <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ciphertext = page.subarray(skip, skip + cipherLength);
|
||||
const iv = page.subarray(skip + cipherLength, skip + cipherLength + IV_SIZE);
|
||||
const decipher = createDecipheriv('aes-256-cbc', key, iv);
|
||||
decipher.setAutoPadding(false);
|
||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write one decrypted page into the plaintext image.
|
||||
*
|
||||
* Page 1 is special in both the main file and the WAL: its first 16 on-disk
|
||||
* bytes hold the KDF salt in place of SQLite's magic, so the magic and the
|
||||
* page-size field have to be restored. Every other page's body starts at offset
|
||||
* 0 of the page.
|
||||
*/
|
||||
function writePlaintextPage(image: Buffer, offset: number, pageNumber: number, body: Buffer): void {
|
||||
if (pageNumber !== 1) {
|
||||
body.copy(image, offset, 0, Math.min(body.length, PAGE_SIZE));
|
||||
return;
|
||||
}
|
||||
|
||||
SQLITE_MAGIC.copy(image, offset);
|
||||
body.copy(image, offset + SALT_SIZE, 0, Math.min(body.length, PAGE_SIZE - SALT_SIZE));
|
||||
image.writeUInt16BE(PAGE_SIZE, offset + 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the `-wal` sidecar into a decrypted image and return the frame count.
|
||||
*
|
||||
* A WAL is not wrapped in SQLCipher's envelope the way the main file is: its
|
||||
* 32-byte header and every 24-byte frame header stay plaintext, and each frame
|
||||
* carries one encrypted page (page 1 keeping its salt prefix). SQLite's frame
|
||||
* checksums are computed over the frames *as stored*, i.e. over the ciphertext,
|
||||
* which is why the intact prefix is established before any decryption.
|
||||
*
|
||||
* Only frames up to the last commit are applied, and the database size recorded
|
||||
* by that commit decides how many pages the merged image has — a torn tail from
|
||||
* a running QQ therefore contributes nothing.
|
||||
*/
|
||||
function applyWal(
|
||||
base: Buffer,
|
||||
wal: Buffer | null,
|
||||
key: Buffer,
|
||||
algorithm: SqlcipherAlgorithm,
|
||||
): DecryptedDatabase {
|
||||
const log = wal === null ? null : parseWal(wal);
|
||||
|
||||
if (!log || log.frames.length === 0) {
|
||||
return { image: base, walFrames: 0 };
|
||||
}
|
||||
|
||||
let lastCommit = -1;
|
||||
|
||||
for (let index = 0; index < log.frames.length; index++) {
|
||||
if (log.frames[index]!.commitSize !== 0) {
|
||||
lastCommit = index;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastCommit < 0) {
|
||||
return { image: base, walFrames: 0 };
|
||||
}
|
||||
|
||||
const pageCount = log.frames[lastCommit]!.commitSize;
|
||||
const basePageCount = base.length / PAGE_SIZE;
|
||||
|
||||
// A commit can only grow the database by the pages its own log carries, so
|
||||
// anything past that bound is a corrupt frame header rather than a size.
|
||||
if (pageCount < 1 || pageCount > basePageCount + log.frames.length) {
|
||||
return { image: base, walFrames: 0 };
|
||||
}
|
||||
|
||||
const merged = Buffer.alloc(pageCount * PAGE_SIZE);
|
||||
base.copy(merged, 0, 0, Math.min(base.length, merged.length));
|
||||
const reserve = reserveBytes(algorithm);
|
||||
|
||||
for (let index = 0; index <= lastCommit; index++) {
|
||||
const frame = log.frames[index]!;
|
||||
|
||||
if (frame.pageNumber === 0 || frame.pageNumber > pageCount) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const page = wal!.subarray(frame.offset, frame.offset + PAGE_SIZE);
|
||||
const body = decryptPageBody(page, key, frame.pageNumber === 1 ? SALT_SIZE : 0, reserve);
|
||||
|
||||
if (body === null) {
|
||||
return { image: base, walFrames: 0 };
|
||||
}
|
||||
|
||||
writePlaintextPage(merged, (frame.pageNumber - 1) * PAGE_SIZE, frame.pageNumber, body);
|
||||
}
|
||||
|
||||
return { image: merged, walFrames: lastCommit + 1 };
|
||||
}
|
||||
|
||||
interface WalFrame {
|
||||
pageNumber: number;
|
||||
/** Database size in pages recorded by a commit frame, 0 for a non-commit frame. */
|
||||
commitSize: number;
|
||||
/** Offset of the frame's page inside the `-wal` buffer. */
|
||||
offset: number;
|
||||
}
|
||||
|
||||
interface WalLog {
|
||||
frames: WalFrame[];
|
||||
littleEndian: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a `-wal` buffer and collect its intact frames.
|
||||
*
|
||||
* Validation stops at the first frame whose checksum does not continue the
|
||||
* chain, mirroring SQLite's own recovery: everything after that frame is either
|
||||
* half-written or belongs to an aborted transaction.
|
||||
*/
|
||||
function parseWal(wal: Buffer): WalLog | null {
|
||||
if (wal.length < WAL_HEADER_SIZE + WAL_FRAME_HEADER_SIZE + PAGE_SIZE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const magic = wal.readUInt32BE(0);
|
||||
const littleEndian = magic === 0x377f0682 ? true : magic === 0x377f0683 ? false : null;
|
||||
|
||||
if (littleEndian === null || wal.readUInt32BE(8) !== PAGE_SIZE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The header checksum covers its first 24 bytes; magic and page size are
|
||||
// excluded from the checksummed range by that length.
|
||||
const header = walChecksum([0, 0], [wal.subarray(0, WAL_HEADER_SIZE - 8)], littleEndian);
|
||||
|
||||
if (header[0] !== wal.readUInt32BE(24) || header[1] !== wal.readUInt32BE(28)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const frameSize = WAL_FRAME_HEADER_SIZE + PAGE_SIZE;
|
||||
const frames: WalFrame[] = [];
|
||||
let running = header;
|
||||
|
||||
for (let offset = WAL_HEADER_SIZE; offset + frameSize <= wal.length; offset += frameSize) {
|
||||
const frameHeader = wal.subarray(offset, offset + WAL_FRAME_HEADER_SIZE);
|
||||
const page = wal.subarray(offset + WAL_FRAME_HEADER_SIZE, offset + frameSize);
|
||||
running = walChecksum(running, [frameHeader.subarray(0, 8), page], littleEndian);
|
||||
|
||||
if (
|
||||
running[0] !== frameHeader.readUInt32BE(16) ||
|
||||
running[1] !== frameHeader.readUInt32BE(20)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
|
||||
frames.push({
|
||||
pageNumber: frameHeader.readUInt32BE(0),
|
||||
commitSize: frameHeader.readUInt32BE(4),
|
||||
offset: offset + WAL_FRAME_HEADER_SIZE,
|
||||
});
|
||||
}
|
||||
|
||||
return { frames, littleEndian };
|
||||
}
|
||||
|
||||
/**
|
||||
* SQLite's rolling WAL checksum: two 32-bit accumulators advanced over 8-byte
|
||||
* word pairs. The byte order is fixed by the WAL magic (`0x377f0682` =
|
||||
* little-endian words, `0x377f0683` = big-endian) and is unrelated to the
|
||||
* host's byte order.
|
||||
*
|
||||
* Every chunk fed in here is a multiple of 8 bytes: the header contributes 24
|
||||
* bytes, a frame its first 8 header bytes plus a whole page.
|
||||
*/
|
||||
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];
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the assembled image self-consistent before it is handed to SQLite.
|
||||
*
|
||||
* The merge can add or remove pages relative to the base image, and the header
|
||||
* fields that describe the database are only trusted while page 1's change
|
||||
* counter and version-valid-for agree. The write/read version bytes are reset
|
||||
* to the legacy journal so the image can be opened read-only without needing a
|
||||
* `-wal`/`-shm` pair next to it.
|
||||
*/
|
||||
function normalizeImage(image: Buffer): void {
|
||||
const pageCount = image.length / PAGE_SIZE;
|
||||
const changeCounter = image.readUInt32BE(24);
|
||||
image[18] = 1;
|
||||
image[19] = 1;
|
||||
image.writeUInt32BE(pageCount, 28);
|
||||
image.writeUInt32BE(changeCounter, 92);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { errorMessage } from '@snowluma/common/errors';
|
||||
import { createLogger, type Logger } from '@snowluma/common/logger';
|
||||
import { watch, type FSWatcher } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { loginDbPaths, qqDataRootsForPid } from './paths';
|
||||
|
||||
export function watchAccountChangesForPids(
|
||||
pids: readonly number[],
|
||||
onChange: () => void,
|
||||
log: Logger = createLogger('QqAccount'),
|
||||
): () => void {
|
||||
const roots = new Set<string>();
|
||||
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
for (const root of qqDataRootsForPid(pid)) {
|
||||
roots.add(root);
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn('account watch root failed: PID=%d err=%s', pid, errorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
return watchAccountFiles([...roots].flatMap(loginDbPaths), onChange, log);
|
||||
}
|
||||
|
||||
/** Watch directories so replacing login.db or recreating its WAL keeps working. */
|
||||
export function watchAccountFiles(
|
||||
dbPaths: readonly string[],
|
||||
onChange: () => void,
|
||||
log: Logger = createLogger('QqAccount'),
|
||||
): () => void {
|
||||
const watchers = new Map<string, FSWatcher>();
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
|
||||
const changed = () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
timer = undefined;
|
||||
onChange();
|
||||
}, 250);
|
||||
timer.unref();
|
||||
};
|
||||
|
||||
const observe = (dbPath: string) => {
|
||||
watchers.get(dbPath)?.close();
|
||||
watchers.delete(dbPath);
|
||||
const dbDirectory = path.dirname(dbPath);
|
||||
let directory = dbDirectory;
|
||||
|
||||
for (;;) {
|
||||
const target = path.relative(directory, dbPath).split(path.sep)[0]!;
|
||||
|
||||
try {
|
||||
const watcher = watch(directory, { persistent: false }, (_event, filename) => {
|
||||
if (
|
||||
watchers.get(dbPath) !== watcher ||
|
||||
(filename !== null &&
|
||||
filename !== target &&
|
||||
!(directory === dbDirectory && filename === `${target}-wal`))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// QQ may create its data directories after process discovery.
|
||||
if (directory !== dbDirectory) {
|
||||
observe(dbPath);
|
||||
}
|
||||
|
||||
changed();
|
||||
});
|
||||
watcher.on('error', (error) => {
|
||||
if (watchers.get(dbPath) !== watcher) {
|
||||
return;
|
||||
}
|
||||
|
||||
watcher.close();
|
||||
watchers.delete(dbPath);
|
||||
log.warn('account watch failed: path=%s err=%s', dbPath, errorMessage(error));
|
||||
});
|
||||
watchers.set(dbPath, watcher);
|
||||
return;
|
||||
} catch (error) {
|
||||
const parent = path.dirname(directory);
|
||||
|
||||
if (
|
||||
error instanceof Error &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT' &&
|
||||
parent !== directory
|
||||
) {
|
||||
directory = parent;
|
||||
continue;
|
||||
}
|
||||
|
||||
log.warn('account watch failed: path=%s err=%s', dbPath, errorMessage(error));
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const dbPath of new Set(dbPaths)) {
|
||||
observe(dbPath);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
|
||||
for (const watcher of watchers.values()) {
|
||||
watcher.close();
|
||||
}
|
||||
|
||||
watchers.clear();
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,121 @@
|
||||
import { getNativeHookAddon, getNativeHookLoadError } from './injector';
|
||||
|
||||
export interface IntegrityReadError {
|
||||
status: 'error';
|
||||
operation: string;
|
||||
code: number;
|
||||
}
|
||||
|
||||
export interface NativeRuntimeIntegrity {
|
||||
pid: number;
|
||||
executablePath: string | null;
|
||||
process: { status: 'ok'; rid: number } | IntegrityReadError;
|
||||
file:
|
||||
| {
|
||||
status: 'ok';
|
||||
label: { rid: number; inherited: boolean; policy: number } | null;
|
||||
}
|
||||
| IntegrityReadError;
|
||||
}
|
||||
|
||||
export type RuntimeIntegrity =
|
||||
| { status: 'unsupported' }
|
||||
| { status: 'unavailable'; error: string }
|
||||
| ({ status: 'available' } & NativeRuntimeIntegrity);
|
||||
|
||||
/** File labels can change after startup; only the actual token decides the warning. */
|
||||
export function hasLowProcessIntegrity(integrity: RuntimeIntegrity): boolean {
|
||||
return (
|
||||
integrity.status === 'available' &&
|
||||
integrity.process.status === 'ok' &&
|
||||
integrity.process.rid < 8192
|
||||
);
|
||||
}
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function uint32(value: unknown): value is number {
|
||||
return (
|
||||
typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 0xffff_ffff
|
||||
);
|
||||
}
|
||||
|
||||
function readError(value: Record<string, unknown>): boolean {
|
||||
return value.status === 'error' && typeof value.operation === 'string' && uint32(value.code);
|
||||
}
|
||||
|
||||
function isNativeRuntimeIntegrity(value: unknown): value is NativeRuntimeIntegrity {
|
||||
if (
|
||||
!record(value) ||
|
||||
value.pid !== process.pid ||
|
||||
!(typeof value.executablePath === 'string' || value.executablePath === null) ||
|
||||
!record(value.process) ||
|
||||
!record(value.file)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!(
|
||||
readError(value.process) ||
|
||||
(value.process.status === 'ok' && uint32(value.process.rid))
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const file = value.file;
|
||||
return (
|
||||
readError(file) ||
|
||||
(file.status === 'ok' &&
|
||||
(file.label === null ||
|
||||
(record(file.label) &&
|
||||
uint32(file.label.rid) &&
|
||||
typeof file.label.inherited === 'boolean' &&
|
||||
uint32(file.label.policy))))
|
||||
);
|
||||
}
|
||||
|
||||
let cached: Promise<RuntimeIntegrity> | undefined;
|
||||
|
||||
/** One startup snapshot, shared by the startup log and the WebUI warning. */
|
||||
export function getRuntimeIntegrity(): Promise<RuntimeIntegrity> {
|
||||
return (cached ??= readRuntimeIntegrity());
|
||||
}
|
||||
|
||||
async function readRuntimeIntegrity(): Promise<RuntimeIntegrity> {
|
||||
if (process.platform !== 'win32') {
|
||||
return { status: 'unsupported' };
|
||||
}
|
||||
|
||||
try {
|
||||
const addon = getNativeHookAddon();
|
||||
|
||||
if (!addon) {
|
||||
throw new Error(getNativeHookLoadError() ?? '原生组件不可用');
|
||||
}
|
||||
|
||||
if (typeof addon.getRuntimeIntegrityAsync !== 'function') {
|
||||
throw new Error('当前原生组件不支持完整性诊断,请更新原生组件');
|
||||
}
|
||||
|
||||
const result = await addon.getRuntimeIntegrityAsync();
|
||||
|
||||
if (!isNativeRuntimeIntegrity(result)) {
|
||||
throw new Error('原生完整性诊断返回了无效数据');
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'available',
|
||||
pid: result.pid,
|
||||
executablePath: result.executablePath,
|
||||
process: result.process,
|
||||
file: result.file,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'unavailable',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { HookProcessBaseInfo } from './injector';
|
||||
|
||||
export type HookProcessStatus =
|
||||
'available' | 'loading' | 'connecting' | 'loaded' | 'online' | 'error' | 'disconnected';
|
||||
|
||||
/** Account the QQ client is signed in as, as reported by its local data. */
|
||||
export interface HookProcessAccount {
|
||||
uin: string;
|
||||
nickname: string;
|
||||
}
|
||||
|
||||
/** Public-facing snapshot of a per-PID HookSession. */
|
||||
export interface HookProcessInfo extends HookProcessBaseInfo {
|
||||
injected: boolean;
|
||||
connected: boolean;
|
||||
loggedIn: boolean;
|
||||
uin: string;
|
||||
status: HookProcessStatus;
|
||||
error: string;
|
||||
method: string;
|
||||
/** Locally detected identity; null means unknown, not necessarily signed out. */
|
||||
account: HookProcessAccount | null;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["node"],
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['tests/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user