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

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

No files matched your search

+69
View File
@@ -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"
}
}
+704
View File
@@ -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';
+73
View File
@@ -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);
}
}
+165
View File
@@ -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
+37
View File
@@ -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';
}
+15
View File
@@ -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';
+137
View File
@@ -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);
}
+8
View File
@@ -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 });
}
+217
View File
@@ -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');
}
}
}
+77
View File
@@ -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;
}
}
+62
View File
@@ -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;
}
+23
View File
@@ -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';
+199
View File
@@ -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 '';
}
+161
View File
@@ -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;
}
+461
View File
@@ -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);
}
+115
View File
@@ -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
+121
View File
@@ -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),
};
}
}
+23
View File
@@ -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();
}
});
});
+514
View File
@@ -0,0 +1,514 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EventEmitter } from 'events';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { HookManager, shouldAutoLoadPid } from '../src/hook-manager';
import { PipeWatcher } from '../src/pipe-watcher';
import type { HookInjectResult, ManualMapHandle } from '../src/injector';
import type { BridgeManagerSink } from '../src/hook-manager';
import type { AccountLoginState } from '../src/qq-account';
import type { QqHookClient } from '../src/qq-hook-client';
import {
createLogger,
getLogLevel,
setLogLevel,
subscribeLogs,
type LogEntry,
} from '@snowluma/common/logger';
const DUMMY_HANDLE: ManualMapHandle = { base: 0n, entry: 0n, exceptionTable: 0n, size: 0 };
const flush = () => new Promise<void>((r) => setImmediate(r));
const previousLogLevel = getLogLevel();
function managerTrace(entries: LogEntry[]): LogEntry[] {
return entries.filter((entry) => entry.level === 'trace' && entry.scope === 'Hook');
}
function makeManager(opts: {
autoLoadOnDiscovery?: boolean;
processes?: number[];
onSessionsChanged?: () => void;
inject?: (pid: number) => Promise<HookInjectResult>;
detectAccounts?: (pids: readonly number[]) => Promise<AccountLoginState[]>;
}) {
let pids = opts.processes ?? [];
const live = new Set<number>();
const inject = vi.fn(
opts.inject ??
(async () => ({
method: 'loadModuleManual' as const,
handle: DUMMY_HANDLE,
})),
);
const unload = vi.fn(async () => {});
const pipeWatcher = new PipeWatcher({
listProcesses: async () => pids.map((pid) => ({ pid, name: 'qq', path: '' })),
listLivePipes: async () => new Set(live),
intervalMs: 60_000, // disable internal timer; tests drive ticks manually
});
// FakeClient — never connected; just needs to satisfy EventEmitter +
// a minimal subset of QqHookClient for HookSession.tearDownClient().
const makeClient = vi.fn(() => {
const c = new EventEmitter() as EventEmitter & Partial<QqHookClient>;
(c as any).isClosed = false;
(c as any).isLoggedIn = false;
(c as any).getLoginState = () => ({ loggedIn: false, uin: '0', uinNumber: 0n });
(c as any).connectAll = async () => {
throw new Error('test: never really connect');
};
(c as any).close = () => {
(c as any).isClosed = true;
};
return c as unknown as QqHookClient;
});
const bridgeManager = {
onPacket: vi.fn(),
onHookLogin: vi.fn(),
onPidDisconnected: vi.fn(),
} as unknown as BridgeManagerSink;
const manager = new HookManager({
bridgeManager,
pipeWatcher,
injector: { inject, unload },
makeClient,
autoLoadOnDiscovery: opts.autoLoadOnDiscovery,
onSessionsChanged: opts.onSessionsChanged,
listProcesses: async () => pids.map((pid) => ({ pid, name: 'qq', path: '' })),
// Detection reads QQ's own data directories; tests stay off the host.
detectAccounts: opts.detectAccounts ?? (async () => []),
watchAccounts: () => () => {},
});
return {
manager,
inject,
pipeWatcher,
setProcesses: (next: number[]) => {
pids = next;
},
};
}
afterEach(() => {
setLogLevel(previousLogLevel);
});
describe('HookManager runtime TRACE', () => {
it('records discovery and process-gone facts while unchanged ticks stay silent', async () => {
const entries: LogEntry[] = [];
const sessionsChanged = vi.fn();
setLogLevel('trace');
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
const ctx = makeManager({
autoLoadOnDiscovery: false,
processes: [],
onSessionsChanged: sessionsChanged,
});
try {
await ctx.pipeWatcher.start();
const baseline = managerTrace(entries).length;
await ctx.pipeWatcher.tickNow();
await ctx.pipeWatcher.tickNow();
expect(managerTrace(entries)).toHaveLength(baseline);
ctx.setProcesses([4242]);
await ctx.pipeWatcher.tickNow();
ctx.setProcesses([]);
await ctx.pipeWatcher.tickNow();
await flush();
const facts = managerTrace(entries).filter((entry) =>
entry.message.startsWith('hook_manager_fact '),
);
expect(facts.map((entry) => entry.message)).toEqual([
expect.stringContaining('hook_manager_fact event=process_discovered pid=4242'),
'hook_manager_fact event=process_gone pid=4242 tracked=true',
]);
expect(facts.every((entry) => entry.req !== undefined)).toBe(true);
} finally {
unsubscribe();
ctx.manager.dispose();
}
});
it('records bounded transient retries and recovery as separate attempts', async () => {
const entries: LogEntry[] = [];
setLogLevel('trace');
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
const ctx = makeManager({ autoLoadOnDiscovery: true, processes: [4242] });
ctx.inject
.mockImplementationOnce(() => {
throw new Error(
'target process does not map /lib/x86_64-linux-gnu/libc.so.6 while resolving mmap',
);
})
.mockImplementationOnce(() => ({
method: 'loadModuleManual' as const,
handle: DUMMY_HANDLE,
}));
try {
await ctx.pipeWatcher.start();
await flush();
await flush();
await ctx.pipeWatcher.tickNow();
await flush();
await flush();
const attempts = managerTrace(entries).filter((entry) =>
entry.message.startsWith('hook_autoload_'),
);
expect(attempts.map((entry) => entry.message)).toEqual([
'hook_autoload_start pid=4242 attempt=1 maxAttempts=3',
expect.stringMatching(
/^hook_autoload_terminal pid=4242 attempt=1 outcome=failed reason=retry_pending error=.*elapsedMs=\d+$/,
),
'hook_autoload_start pid=4242 attempt=2 maxAttempts=3',
expect.stringMatching(
/^hook_autoload_terminal pid=4242 attempt=2 outcome=completed reason=recovered state=.*elapsedMs=\d+$/,
),
]);
expect(attempts[0]!.req).toBe(attempts[1]!.req);
expect(attempts[2]!.req).toBe(attempts[3]!.req);
expect(attempts[0]!.req).not.toBe(attempts[2]!.req);
} finally {
unsubscribe();
ctx.manager.dispose();
}
});
it('ends an in-flight auto-load as dropped when a manual load supersedes it', async () => {
const entries: LogEntry[] = [];
let resolveInjection!: (result: HookInjectResult) => void;
const injection = new Promise<HookInjectResult>((resolve) => {
resolveInjection = resolve;
});
setLogLevel('trace');
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
const ctx = makeManager({
autoLoadOnDiscovery: true,
processes: [4242],
inject: () => injection,
});
try {
await ctx.pipeWatcher.start();
await flush();
const manualLoad = ctx.manager.loadProcess(4242);
resolveInjection({ method: 'loadModuleManual', handle: DUMMY_HANDLE });
await manualLoad;
await flush();
const attempts = managerTrace(entries).filter((entry) =>
entry.message.startsWith('hook_autoload_'),
);
expect(attempts.map((entry) => entry.message)).toEqual([
'hook_autoload_start pid=4242 attempt=1 maxAttempts=3',
expect.stringMatching(
/^hook_autoload_terminal pid=4242 attempt=1 outcome=dropped reason=superseded elapsedMs=\d+$/,
),
]);
expect(attempts[0]!.req).toBe(attempts[1]!.req);
} finally {
unsubscribe();
ctx.manager.dispose();
}
});
it.each([
{
name: 'permanent failure',
error: 'ptrace denied',
ticks: 1,
attempts: 1,
reason: 'permanent_failure',
},
{
name: 'retry exhaustion',
error: 'target process does not map /lib/libc.so.6 while resolving mmap',
ticks: 4,
attempts: 3,
reason: 'retry_exhausted',
},
])('records $name terminal', async ({ error, ticks, attempts, reason }) => {
const entries: LogEntry[] = [];
setLogLevel('trace');
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
const ctx = makeManager({ autoLoadOnDiscovery: true, processes: [4242] });
ctx.inject.mockImplementation(() => {
throw new Error(error);
});
try {
await ctx.pipeWatcher.start();
await flush();
await flush();
for (let tick = 0; tick < ticks; tick += 1) {
await ctx.pipeWatcher.tickNow();
await flush();
await flush();
}
const starts = managerTrace(entries).filter((entry) =>
entry.message.startsWith('hook_autoload_start '),
);
const terminals = managerTrace(entries).filter((entry) =>
entry.message.startsWith('hook_autoload_terminal '),
);
expect(starts).toHaveLength(attempts);
expect(terminals).toHaveLength(attempts);
expect(terminals.at(-1)!.message).toContain(`reason=${reason}`);
} finally {
unsubscribe();
ctx.manager.dispose();
}
});
});
describe('HookManager.autoLoadOnDiscovery', () => {
it('does NOT inject on process-discovered when flag is off', async () => {
const ctx = makeManager({ autoLoadOnDiscovery: false, processes: [4242] });
await ctx.pipeWatcher.start();
await flush();
expect(ctx.inject).not.toHaveBeenCalled();
ctx.manager.dispose();
});
it('injects every newly-discovered PID when flag is on', async () => {
const ctx = makeManager({ autoLoadOnDiscovery: true, processes: [4242] });
await ctx.pipeWatcher.start();
// session.load() is queued via a per-session promise chain; flush twice
// so the auto-load runs to completion before we assert.
await flush();
await flush();
expect(ctx.inject).toHaveBeenCalledTimes(1);
expect(ctx.inject).toHaveBeenCalledWith(4242);
// A second PID appearing mid-flight should also trigger an inject.
ctx.setProcesses([4242, 9999]);
await ctx.pipeWatcher.tickNow();
await flush();
await flush();
expect(ctx.inject).toHaveBeenCalledTimes(2);
expect(ctx.inject).toHaveBeenLastCalledWith(9999);
ctx.manager.dispose();
});
it('swallows inject errors so the watcher keeps running', async () => {
const ctx = makeManager({ autoLoadOnDiscovery: true, processes: [4242] });
ctx.inject.mockImplementationOnce(() => {
throw new Error('ptrace denied');
});
await ctx.pipeWatcher.start();
await flush();
await flush();
// Second PID should still be auto-loaded — the failure on 4242 must
// not leak out of the listener and break the watcher's emit loop.
ctx.setProcesses([4242, 9999]);
await ctx.pipeWatcher.tickNow();
await flush();
await flush();
expect(ctx.inject).toHaveBeenCalledTimes(2);
ctx.manager.dispose();
});
it('retries a transient libc mapping failure on the next watcher tick', async () => {
const ctx = makeManager({ autoLoadOnDiscovery: true, processes: [4242] });
ctx.inject.mockImplementationOnce(() => {
throw new Error(
'target process does not map /lib/x86_64-linux-gnu/libc.so.6 while resolving mmap',
);
});
await ctx.pipeWatcher.start();
await flush();
await flush();
expect(ctx.inject).toHaveBeenCalledTimes(1);
await ctx.pipeWatcher.tickNow();
await flush();
await flush();
expect(ctx.inject).toHaveBeenCalledTimes(2);
expect(ctx.inject).toHaveBeenLastCalledWith(4242);
ctx.manager.dispose();
});
it('does not retry permanent auto-load errors', async () => {
const ctx = makeManager({ autoLoadOnDiscovery: true, processes: [4242] });
ctx.inject.mockImplementation(() => {
throw new Error('ptrace denied');
});
await ctx.pipeWatcher.start();
await flush();
await flush();
await ctx.pipeWatcher.tickNow();
await flush();
await flush();
expect(ctx.inject).toHaveBeenCalledTimes(1);
ctx.manager.dispose();
});
it('stops retrying a transient libc mapping failure after three attempts', async () => {
const ctx = makeManager({ autoLoadOnDiscovery: true, processes: [4242] });
ctx.inject.mockImplementation(() => {
throw new Error(
'target process does not map /lib/x86_64-linux-gnu/libc.so.6 while resolving mmap',
);
});
await ctx.pipeWatcher.start();
await flush();
await flush();
for (let tick = 0; tick < 4; tick += 1) {
await ctx.pipeWatcher.tickNow();
await flush();
await flush();
}
expect(ctx.inject).toHaveBeenCalledTimes(3);
ctx.manager.dispose();
});
});
// `shouldAutoLoadPid` reads /proc/<pid>/cmdline directly, so we point
// it at a temporary directory that mimics the procfs layout. This lets
// the tests run on macOS / CI without a live Linux QQ process.
describe('shouldAutoLoadPid', () => {
let tmpProc: string;
const originalReadFileSync = fs.readFileSync;
const log = createLogger('test');
const originalPlatform = process.platform;
const originalAutoLoadAll = process.env.SNOWLUMA_HOOK_AUTOLOAD_ALL;
beforeEach(() => {
tmpProc = fs.mkdtempSync(path.join(os.tmpdir(), 'hookmgr-proc-'));
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
delete process.env.SNOWLUMA_HOOK_AUTOLOAD_ALL;
// Redirect readFileSync('/proc/<pid>/cmdline') to the tmp dir.
(fs.readFileSync as unknown as typeof fs.readFileSync) = ((
p: string,
...rest: unknown[]
) => {
const match = /^\/proc\/(\d+)\/cmdline$/.exec(p);
if (match) {
return originalReadFileSync(
path.join(tmpProc, match[1], 'cmdline'),
...(rest as [BufferEncoding]),
);
}
return originalReadFileSync(p, ...(rest as [BufferEncoding]));
}) as typeof fs.readFileSync;
});
afterEach(() => {
(fs.readFileSync as unknown as typeof fs.readFileSync) = originalReadFileSync;
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
if (originalAutoLoadAll === undefined) {
delete process.env.SNOWLUMA_HOOK_AUTOLOAD_ALL;
} else {
process.env.SNOWLUMA_HOOK_AUTOLOAD_ALL = originalAutoLoadAll;
}
fs.rmSync(tmpProc, { recursive: true, force: true });
});
function writeCmdline(pid: number, args: string[]): void {
const dir = path.join(tmpProc, String(pid));
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'cmdline'), args.join('\0') + '\0');
}
it('allows the QQ main process (no --type=)', () => {
writeCmdline(50, ['qq', '--no-sandbox']);
expect(shouldAutoLoadPid(50, log)).toBe(true);
});
it('rejects Electron zygotes', () => {
writeCmdline(59, ['/opt/QQ/qq', '--type=zygote', '--no-zygote-sandbox', '--no-sandbox']);
expect(shouldAutoLoadPid(59, log)).toBe(false);
});
it('rejects renderer/gpu/utility children', () => {
writeCmdline(70, ['/opt/QQ/qq', '--type=renderer']);
writeCmdline(71, ['/opt/QQ/qq', '--type=gpu-process']);
writeCmdline(72, [
'/opt/QQ/qq',
'--type=utility',
'--utility-sub-type=network.mojom.NetworkService',
]);
expect(shouldAutoLoadPid(70, log)).toBe(false);
expect(shouldAutoLoadPid(71, log)).toBe(false);
expect(shouldAutoLoadPid(72, log)).toBe(false);
});
it('allows everything if SNOWLUMA_HOOK_AUTOLOAD_ALL=1 (escape hatch)', () => {
writeCmdline(59, ['/opt/QQ/qq', '--type=zygote']);
process.env.SNOWLUMA_HOOK_AUTOLOAD_ALL = '1';
expect(shouldAutoLoadPid(59, log)).toBe(true);
});
it('allows on non-linux platforms (filter is a Linux-only workaround)', () => {
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
// No cmdline file needed — we return true before touching procfs.
expect(shouldAutoLoadPid(59, log)).toBe(true);
});
it('allows when /proc is unreadable (dead PID, permission issue)', () => {
expect(shouldAutoLoadPid(99999, log)).toBe(true);
});
});
describe('HookManager — signed-in account labelling', () => {
it('labels each process with the account detected on it', async () => {
const ctx = makeManager({
processes: [4242, 5151],
detectAccounts: async () => [
{ uin: '10001', uid: 'u_a', nickname: 'Alice', pid: 4242 },
{ uin: '10002', uid: 'u_b', nickname: 'Bob', pid: null },
],
});
try {
const list = await ctx.manager.listProcesses();
expect(list.map((info) => [info.pid, info.account])).toEqual([
[4242, { uin: '10001', nickname: 'Alice' }],
[5151, null],
]);
} finally {
ctx.manager.dispose();
}
});
it('leaves processes unlabelled when detection fails', async () => {
const ctx = makeManager({
processes: [4242],
detectAccounts: async () => {
throw new Error('login.db unreadable');
},
});
try {
const list = await ctx.manager.listProcesses();
expect(list[0]!.account).toBeNull();
} finally {
ctx.manager.dispose();
}
});
});
@@ -0,0 +1,70 @@
import { describe, expect, it, vi } from 'vitest';
import { HookPacketClient } from '../src/hook-packet-client';
import {
HookPipeRequestError,
PIPE_STATUS_CONNECTION_UNAVAILABLE,
type QqHookClient,
} from '../src/qq-hook-client';
function fakeClient(send: QqHookClient['send']): QqHookClient {
return {
isLoggedIn: true,
send,
} as unknown as QqHookClient;
}
describe('HookPacketClient outbound health', () => {
it('reports native connection loss once and recovers after a successful request', async () => {
const healthChanged = vi.fn();
const send = vi
.fn<QqHookClient['send']>()
.mockRejectedValueOnce(
new HookPipeRequestError(
'The QQ connection changed. Please restart QQ and try again.',
PIPE_STATUS_CONNECTION_UNAVAILABLE,
1,
),
)
.mockRejectedValueOnce(
new HookPipeRequestError(
'The QQ connection changed. Please restart QQ and try again.',
PIPE_STATUS_CONNECTION_UNAVAILABLE,
2,
),
)
.mockResolvedValueOnce({
requestId: 3,
error: 0,
message: '',
body: Buffer.from('ok'),
});
const sender = new HookPacketClient(fakeClient(send), healthChanged);
await expect(sender.sendPacket('Test.Command', Buffer.alloc(0))).resolves.toMatchObject({
success: false,
gotResponse: false,
});
await sender.sendPacket('Test.Command', Buffer.alloc(0));
await expect(sender.sendPacket('Test.Command', Buffer.alloc(0))).resolves.toMatchObject({
success: true,
gotResponse: true,
});
expect(healthChanged.mock.calls).toEqual([[false], [true]]);
});
it('does not classify ordinary request rejection as connection loss', async () => {
const healthChanged = vi.fn();
const send = vi
.fn<QqHookClient['send']>()
.mockRejectedValue(new HookPipeRequestError('This request is not valid.', -38, 1));
const sender = new HookPacketClient(fakeClient(send), healthChanged);
await expect(sender.sendPacket('Test.Command', Buffer.alloc(0))).resolves.toMatchObject({
success: false,
gotResponse: false,
});
expect(healthChanged).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,115 @@
// E2E for Docker "QQ never logs in + pipe keeps reconnecting" scenario.
//
// Drives the real PipeWatcher + HookManager through repeated same-PID
// pipe up/down cycles via tickNow() and asserts the BridgeManager mock
// is never touched (no login → no Bridge / OneBotInstance / Identity).
import { describe, it, expect, vi } from 'vitest';
import { EventEmitter } from 'events';
import { HookManager } from '../src/hook-manager';
import { PipeWatcher } from '../src/pipe-watcher';
import type { ManualMapHandle } from '../src/injector';
import type { BridgeManagerSink } from '../src/hook-manager';
import type { QqHookClient } from '../src/qq-hook-client';
const DUMMY_HANDLE: ManualMapHandle = { base: 0n, entry: 0n, exceptionTable: 0n, size: 0 };
const flush = () => new Promise<void>((r) => setImmediate(r));
class FakeClient extends EventEmitter {
isClosed = false;
isLoggedIn = false;
async connectAll(_opts: { recv: boolean }) {
if (this.isClosed) {
throw new Error('client is closed');
}
}
getLoginState() {
return { loggedIn: false, uin: '0', uinNumber: 0n };
}
close() {
if (this.isClosed) {
return;
}
this.isClosed = true;
this.emit('close');
}
}
describe('HookManager — Docker reconnect-without-login leak guard', () => {
it('after N pipe up/down cycles: no Bridge calls, no client accumulation', async () => {
const PID = 61;
const liveSet = new Set<number>([PID]);
const clients: FakeClient[] = [];
const pipeWatcher = new PipeWatcher({
listProcesses: async () => [{ pid: PID, name: 'qq', path: '' }],
listLivePipes: async () => new Set(liveSet),
intervalMs: 60_000, // manual ticks only
});
const bridgeManager = {
onPacket: vi.fn(),
onHookLogin: vi.fn(),
onPidDisconnected: vi.fn(),
} as unknown as BridgeManagerSink;
const manager = new HookManager({
bridgeManager,
pipeWatcher,
injector: {
inject: vi.fn(async () => ({
method: 'loadModuleManual' as const,
handle: DUMMY_HANDLE,
})),
unload: vi.fn(async () => {}),
},
makeClient: () => {
const c = new FakeClient();
clients.push(c);
return c as unknown as QqHookClient;
},
autoLoadOnDiscovery: true,
listProcesses: async () => [{ pid: PID, name: 'qq', path: '' }],
detectAccounts: async () => [],
watchAccounts: () => () => {},
});
// Initial discovery + first connect.
await pipeWatcher.start();
await flush();
await flush();
// Drive 50 pipe up/down cycles with the QQ process staying alive but
// the pipe (= QQ control socket) bouncing every "tick".
const CYCLES = 50;
for (let i = 0; i < CYCLES; i++) {
liveSet.delete(PID);
await pipeWatcher.tickNow();
await flush();
liveSet.add(PID);
await pipeWatcher.tickNow();
await flush();
}
// The "QQ never logs in" hypothesis: BridgeManager should never have
// been told about a login or a disconnect.
expect(bridgeManager.onHookLogin).not.toHaveBeenCalled();
expect(bridgeManager.onPidDisconnected).not.toHaveBeenCalled();
expect(bridgeManager.onPacket).not.toHaveBeenCalled();
// Hook layer should hold at most one live client.
const alive = clients.filter((c) => !c.isClosed);
expect(
alive.length,
`live clients after ${CYCLES} cycles: ${alive.length}`,
).toBeLessThanOrEqual(1);
// Total clients minted = roughly one per up cycle. Sanity bound:
// we must have actually exercised the reconnect path.
expect(clients.length).toBeGreaterThan(1);
manager.dispose();
});
});
@@ -0,0 +1,146 @@
// Regression test for the Docker "QQ-never-logs-in + pipe keeps reconnecting" leak hypothesis.
//
// User-reported pattern (Docker, idle login screen):
// 02:41:23 [Hook] pipe connected: PID=61
// 04:37:55 [Hook] pipe connected: PID=61 (same PID, ~2h later)
// 04:40:44 [Hook] pipe connected: PID=61
// 04:43:48 [Hook] pipe connected: PID=61 → eventual OOM-kill
//
// Hypothesis (H2): every onPipeDown → onPipeUp cycle leaks the prior
// QqHookClient (listeners or socket-ref keeping it pinned in memory),
// so repeated reconnect drives unbounded growth.
//
// This test drives N up/down cycles against a HookSession with a FakeClient
// factory and asserts:
// - at most one client is alive at any point
// - every prior client's EventEmitter listener count drops to 0 after teardown
import { describe, it, expect, onTestFinished, vi } from 'vitest';
import { EventEmitter } from 'events';
import { HookSession } from '../src/hook-session';
import type { ManualMapHandle } from '../src/injector';
import type { QqHookClient } from '../src/qq-hook-client';
const DUMMY_HANDLE: ManualMapHandle = { base: 0n, entry: 0n, exceptionTable: 0n, size: 0 };
class FakeClient extends EventEmitter {
isClosed = false;
isLoggedIn = false;
private loginState = { loggedIn: false, uin: '0', uinNumber: 0n };
async connectAll(_opts: { recv: boolean }): Promise<void> {
if (this.isClosed) {
throw new Error('client is closed');
}
}
getLoginState() {
return { ...this.loginState };
}
close(): void {
if (this.isClosed) {
return;
}
this.isClosed = true;
this.emit('close');
}
}
function totalListenerCount(emitter: EventEmitter): number {
return emitter.eventNames().reduce((n, evt) => n + emitter.listenerCount(evt), 0);
}
const flush = () => new Promise<void>((r) => setImmediate(r));
describe('HookSession — pipe reconnect with QQ never logging in', () => {
it('does not accumulate live FakeClient instances across N pipe up/down cycles', async () => {
let pipeLive = true;
const clients: FakeClient[] = [];
const session = new HookSession(61, {
detectLoginUin: async () => null,
injector: {
inject: vi.fn(async () => ({
method: 'loadModuleManual' as const,
handle: DUMMY_HANDLE,
})),
unload: vi.fn(async () => {}),
},
makeClient: () => {
const c = new FakeClient();
clients.push(c);
return c as unknown as QqHookClient;
},
pipeWatcher: { isPipeLive: () => pipeLive },
});
onTestFinished(() => session.dispose());
await session.load();
session.onPipeUp();
await flush();
const CYCLES = 50;
for (let i = 0; i < CYCLES; i++) {
pipeLive = false;
session.onPipeDown();
await flush();
pipeLive = true;
session.onPipeUp();
await flush();
}
const alive = clients.filter((c) => !c.isClosed);
expect.soft(clients.length).toBeGreaterThan(1);
expect(
alive.length,
`expected ≤1 live client after ${CYCLES} reconnects, got ${alive.length}`,
).toBeLessThanOrEqual(1);
});
it('drops all listeners from each torn-down client', async () => {
let pipeLive = true;
const clients: FakeClient[] = [];
const session = new HookSession(61, {
detectLoginUin: async () => null,
injector: {
inject: vi.fn(async () => ({
method: 'loadModuleManual' as const,
handle: DUMMY_HANDLE,
})),
unload: vi.fn(async () => {}),
},
makeClient: () => {
const c = new FakeClient();
clients.push(c);
return c as unknown as QqHookClient;
},
pipeWatcher: { isPipeLive: () => pipeLive },
});
onTestFinished(() => session.dispose());
await session.load();
session.onPipeUp();
await flush();
const CYCLES = 20;
for (let i = 0; i < CYCLES; i++) {
pipeLive = false;
session.onPipeDown();
await flush();
pipeLive = true;
session.onPipeUp();
await flush();
}
// Every client except the current one should have 0 listeners attached.
// (HookSession.tearDownClient does client.removeAllListeners().)
for (let i = 0; i < clients.length - 1; i++) {
const lc = totalListenerCount(clients[i]);
expect(
lc,
`client #${i} of ${clients.length} still has ${lc} listeners after teardown`,
).toBe(0);
}
});
});
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest';
import { resolveHookRuntimeDir } from '../src/hook-runtime-dir';
function procReader(files: Record<string, string>) {
return (file: string): string => {
const value = files[file];
if (value === undefined) {
throw new Error(`missing fixture: ${file}`);
}
return value;
};
}
describe('resolveHookRuntimeDir — Linux target process ownership', () => {
it('uses the QQ process runtime directory instead of the supervisor user directory', () => {
expect(
resolveHookRuntimeDir(46301, {
platform: 'linux',
env: { XDG_RUNTIME_DIR: '/run/user/0' },
ownUid: 0,
readText: procReader({
'/proc/46301/environ': 'HOME=/home/rikka\0XDG_RUNTIME_DIR=/run/user/1000\0',
'/proc/46301/status': 'Name:\tqq\nUid:\t1000\t1000\t1000\t1000\n',
}),
}),
).toBe('/run/user/1000');
});
it('falls back to the QQ process uid when its environment has no runtime directory', () => {
expect(
resolveHookRuntimeDir(46301, {
platform: 'linux',
env: { XDG_RUNTIME_DIR: '/run/user/0' },
ownUid: 0,
readText: procReader({
'/proc/46301/environ': 'HOME=/home/rikka\0',
'/proc/46301/status': 'Name:\tqq\nUid:\t1000\t1000\t1000\t1000\n',
}),
}),
).toBe('/tmp/snowluma-1000');
});
it('fails visibly when target ownership cannot be determined', () => {
expect(() =>
resolveHookRuntimeDir(46301, {
platform: 'linux',
env: { XDG_RUNTIME_DIR: '/run/user/0' },
ownUid: 0,
readText: () => {
throw new Error('permission denied');
},
}),
).toThrow(/cannot resolve hook runtime directory.*permission denied/);
});
it('uses the current convention when the target disappears during discovery', () => {
const gone = Object.assign(new Error('process disappeared'), { code: 'ENOENT' });
expect(
resolveHookRuntimeDir(46301, {
platform: 'linux',
env: { XDG_RUNTIME_DIR: '/run/user/0' },
ownUid: 0,
readText: () => {
throw gone;
},
}),
).toBe('/run/user/0');
});
it('honours an explicit runtime directory inherited by the QQ process', () => {
expect(
resolveHookRuntimeDir(46301, {
platform: 'linux',
env: {},
ownUid: 0,
readText: procReader({
'/proc/46301/environ': 'SNOWLUMA_HOOK_RUNTIME_DIR=/srv/snowluma-hook\0',
'/proc/46301/status': 'Uid:\t1000\t1000\t1000\t1000\n',
}),
}),
).toBe('/srv/snowluma-hook');
});
});
File diff suppressed because it is too large. Load diff
+68
View File
@@ -0,0 +1,68 @@
import { describe, it, expect } from 'vitest';
import { statusFor, type HookFlags } from '../src/hook-status';
import type { HookProcessStatus } from '../src/types';
const F = (o: Partial<HookFlags>): HookFlags => ({
injected: false,
connected: false,
loggedIn: false,
wasLoggedIn: false,
...o,
});
describe('statusFor — settled HookProcessStatus derivation', () => {
it('not injected → available (regardless of other flags)', () => {
expect(statusFor(F({ injected: false }))).toBe('available');
// !injected dominates: stale connected/loggedIn bits must not leak through.
expect(
statusFor(F({ injected: false, connected: true, loggedIn: true, wasLoggedIn: true })),
).toBe('available');
});
it('injected, connected, loggedIn → online', () => {
expect(statusFor(F({ injected: true, connected: true, loggedIn: true }))).toBe('online');
});
it('injected, connected, not loggedIn → loaded', () => {
expect(statusFor(F({ injected: true, connected: true, loggedIn: false }))).toBe('loaded');
});
it('injected, not connected, had logged in → disconnected', () => {
expect(statusFor(F({ injected: true, connected: false, wasLoggedIn: true }))).toBe(
'disconnected',
);
});
it('injected, not connected, never logged in → connecting', () => {
expect(statusFor(F({ injected: true, connected: false, wasLoggedIn: false }))).toBe(
'connecting',
);
});
it('disconnected vs connecting differ ONLY by wasLoggedIn (same injected+!connected)', () => {
const base = { injected: true, connected: false, loggedIn: false };
expect(statusFor(F({ ...base, wasLoggedIn: true }))).toBe('disconnected');
expect(statusFor(F({ ...base, wasLoggedIn: false }))).toBe('connecting');
});
it('covers exactly the five settled states across the flag space', () => {
const seen = new Set<HookProcessStatus>();
for (const injected of [false, true]) {
for (const connected of [false, true]) {
for (const loggedIn of [false, true]) {
for (const wasLoggedIn of [false, true]) {
seen.add(statusFor({ injected, connected, loggedIn, wasLoggedIn }));
}
}
}
}
expect([...seen].sort()).toEqual(
['available', 'connecting', 'disconnected', 'loaded', 'online'].sort(),
);
// The two out-of-band states are never produced by the pure derivation.
expect(seen.has('loading')).toBe(false);
expect(seen.has('error')).toBe(false);
});
});
+426
View File
@@ -0,0 +1,426 @@
import path from 'path';
import { fileURLToPath } from 'url';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const { existsSyncMock, listPidsMock } = vi.hoisted(() => ({
existsSyncMock: vi.fn<(target: string) => boolean>(() => false),
listPidsMock: vi.fn(() => new Set<number>()),
}));
vi.mock('fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs')>();
return {
...actual,
existsSync: (target: Parameters<typeof actual.existsSync>[0]) =>
existsSyncMock(String(target)),
};
});
vi.mock('../src/qq-hook-client', () => ({
listSnowlumaPipePidsSync: listPidsMock,
}));
const NATIVE_PATH_DIR = path.dirname(
fileURLToPath(new URL('../../common/src/native-path.ts', import.meta.url)),
);
const ORIGINAL_PLATFORM = process.platform;
const ORIGINAL_ARCH = process.arch;
const HANDLE = {
base: 0x7fff0000n,
entry: 0x7fff1234n,
exceptionTable: 0x7fff5678n,
size: 8192,
};
function setPlatform(platform: NodeJS.Platform, arch: string): void {
Object.defineProperty(process, 'platform', { value: platform, configurable: true });
Object.defineProperty(process, 'arch', { value: arch, configurable: true });
}
function restorePlatform(): void {
Object.defineProperty(process, 'platform', { value: ORIGINAL_PLATFORM, configurable: true });
Object.defineProperty(process, 'arch', { value: ORIGINAL_ARCH, configurable: true });
}
function nativeSearchDirs(): string[] {
return [
path.resolve(NATIVE_PATH_DIR, 'native'),
path.resolve(NATIVE_PATH_DIR, '..', '..', 'runtime', 'native'),
];
}
function nativeCandidate(fileName: string, dirIndex: number): string {
return path.join(nativeSearchDirs()[dirIndex]!, fileName);
}
function allowExisting(paths: readonly string[]): void {
const allowed = new Set(paths);
existsSyncMock.mockImplementation((target) => allowed.has(target));
}
function fakeAddon(
overrides: {
getAllMainProcessAsync?: number[];
loadModuleManualAsync?: (pid: number, dylibPath: string) => Promise<typeof HANDLE>;
unloadModuleManualAsync?: (pid: number, handle: typeof HANDLE) => Promise<void>;
} = {},
) {
return {
getAllMainProcessAsync: vi.fn(async () => overrides.getAllMainProcessAsync ?? []),
loadModuleManualAsync: vi.fn(overrides.loadModuleManualAsync ?? (async () => HANDLE)),
unloadModuleManualAsync: vi.fn(
overrides.unloadModuleManualAsync ?? (async () => undefined),
),
};
}
function installDlopen(addon: object) {
return vi.spyOn(process, 'dlopen').mockImplementation((mod) => {
(mod as { exports: object }).exports = addon;
});
}
async function loadInjector() {
return import('../src/injector');
}
beforeEach(() => {
existsSyncMock.mockReset();
existsSyncMock.mockReturnValue(false);
listPidsMock.mockReset();
listPidsMock.mockReturnValue(new Set());
vi.resetModules();
});
afterEach(() => {
restorePlatform();
vi.restoreAllMocks();
});
describe('resolveHookNativePath', () => {
it('returns null after probing every search directory', async () => {
setPlatform('linux', 'x64');
const probed: string[] = [];
existsSyncMock.mockImplementation((target) => {
probed.push(target);
return false;
});
const { resolveHookNativePath } = await loadInjector();
expect(resolveHookNativePath('node')).toBeNull();
expect(probed).toEqual([
nativeCandidate('snowluma-linux-x64.node', 0),
nativeCandidate('snowluma-linux-x64.node', 1),
]);
});
it('returns the first existing candidate and stops scanning', async () => {
setPlatform('linux', 'x64');
const winner = nativeCandidate('snowluma-linux-x64.node', 0);
const later = nativeCandidate('snowluma-linux-x64.node', 1);
const probed: string[] = [];
existsSyncMock.mockImplementation((target) => {
probed.push(target);
return target === winner || target === later;
});
const { resolveHookNativePath } = await loadInjector();
expect(resolveHookNativePath('node')).toBe(winner);
expect(probed).toEqual([winner]);
});
it('skips missing earlier directories for the injectable .so', async () => {
setPlatform('linux', 'arm64');
const winner = nativeCandidate('snowluma-linux-arm64.so', 1);
allowExisting([winner]);
const { resolveHookNativePath } = await loadInjector();
expect(resolveHookNativePath('so')).toBe(winner);
});
it('resolves snowluma-win32-x64.dll on win32 x64', async () => {
setPlatform('win32', 'x64');
const winner = nativeCandidate('snowluma-win32-x64.dll', 1);
allowExisting([winner]);
const { resolveHookNativePath } = await loadInjector();
expect(resolveHookNativePath('dll')).toBe(winner);
});
it('uses the generic platform-arch name on win32 arm64', async () => {
setPlatform('win32', 'arm64');
const winner = nativeCandidate('snowluma-win32-arm64.node', 1);
allowExisting([winner]);
const { resolveHookNativePath } = await loadInjector();
expect(resolveHookNativePath('node')).toBe(winner);
});
it('does not load an unrelated native addon from the working directory', async () => {
setPlatform('darwin', 'arm64');
vi.spyOn(process, 'cwd').mockReturnValue('/tmp/sl-injector-cwd');
const winner = path.join(
'/tmp/sl-injector-cwd',
'dist',
'native',
'snowluma-darwin-arm64.node',
);
allowExisting([winner]);
const { resolveHookNativePath } = await loadInjector();
expect(resolveHookNativePath('node')).toBeNull();
});
});
describe('getNativeHookAddon / getNativeHookLoadError', () => {
it('starts with a null load error before any probe', async () => {
const { getNativeHookLoadError } = await loadInjector();
expect(getNativeHookLoadError()).toBeNull();
});
it('returns null and records a missing-binary error', async () => {
setPlatform('linux', 'x64');
const dlopen = vi.spyOn(process, 'dlopen');
const { getNativeHookAddon, getNativeHookLoadError } = await loadInjector();
expect(getNativeHookAddon()).toBeNull();
expect(getNativeHookLoadError()).toBe('No hook native addon found for linux-x64');
expect(dlopen).not.toHaveBeenCalled();
});
it('dlopens the resolved .node once and caches the addon', async () => {
setPlatform('linux', 'x64');
const nodePath = nativeCandidate('snowluma-linux-x64.node', 0);
allowExisting([nodePath]);
const addon = fakeAddon();
const dlopen = installDlopen(addon);
const { getNativeHookAddon, getNativeHookLoadError } = await loadInjector();
const first = getNativeHookAddon();
const second = getNativeHookAddon();
expect(first).toBe(addon);
expect(second).toBe(addon);
expect(getNativeHookLoadError()).toBeNull();
expect(dlopen).toHaveBeenCalledTimes(1);
expect(dlopen.mock.calls[0]![1]).toBe(nodePath);
});
it('stores Error.message when process.dlopen throws', async () => {
setPlatform('darwin', 'arm64');
allowExisting([nativeCandidate('snowluma-darwin-arm64.node', 0)]);
vi.spyOn(process, 'dlopen').mockImplementation(() => {
throw new Error('not a valid Node addon');
});
const { getNativeHookAddon, getNativeHookLoadError } = await loadInjector();
expect(getNativeHookAddon()).toBeNull();
expect(getNativeHookLoadError()).toBe('not a valid Node addon');
});
it('stringifies a non-Error dlopen failure', async () => {
setPlatform('linux', 'x64');
allowExisting([nativeCandidate('snowluma-linux-x64.node', 0)]);
vi.spyOn(process, 'dlopen').mockImplementation(() => {
throw 42;
});
const { getNativeHookAddon, getNativeHookLoadError } = await loadInjector();
expect(getNativeHookAddon()).toBeNull();
expect(getNativeHookLoadError()).toBe('42');
});
it('retries after a failed load when a binary later appears', async () => {
setPlatform('linux', 'x64');
const nodePath = nativeCandidate('snowluma-linux-x64.node', 1);
const addon = fakeAddon();
installDlopen(addon);
const { getNativeHookAddon, getNativeHookLoadError } = await loadInjector();
expect(getNativeHookAddon()).toBeNull();
expect(getNativeHookLoadError()).toBe('No hook native addon found for linux-x64');
allowExisting([nodePath]);
expect(getNativeHookAddon()).toBe(addon);
expect(getNativeHookLoadError()).toBeNull();
});
});
describe('listHookProcesses', () => {
it('rejects an obsolete addon without calling its synchronous enumerator', async () => {
setPlatform('win32', 'x64');
allowExisting([nativeCandidate('snowluma-win32-x64.node', 0)]);
const getAllMainProcess = vi.fn(() => [9]);
installDlopen({ getAllMainProcess });
const { listHookProcesses } = await loadInjector();
await expect(listHookProcesses()).rejects.toThrow('getAllMainProcessAsync');
expect(getAllMainProcess).not.toHaveBeenCalled();
});
it('on darwin maps sorted pipe pids without opening the native addon', async () => {
setPlatform('darwin', 'arm64');
listPidsMock.mockReturnValue(new Set([42, 7, 15]));
allowExisting([nativeCandidate('snowluma-darwin-arm64.node', 0)]);
const dlopen = installDlopen(fakeAddon({ getAllMainProcessAsync: [99] }));
const { listHookProcesses } = await loadInjector();
expect(await listHookProcesses()).toEqual([
{ pid: 7, name: 'qq', path: '' },
{ pid: 15, name: 'qq', path: '' },
{ pid: 42, name: 'qq', path: '' },
]);
expect(listPidsMock).toHaveBeenCalledTimes(1);
expect(dlopen).not.toHaveBeenCalled();
});
it('on darwin returns an empty list when no control sockets exist', async () => {
setPlatform('darwin', 'arm64');
listPidsMock.mockReturnValue(new Set());
const { listHookProcesses } = await loadInjector();
expect(await listHookProcesses()).toEqual([]);
});
it('reports a missing addon instead of an empty process set', async () => {
setPlatform('linux', 'x64');
const { listHookProcesses } = await loadInjector();
await expect(listHookProcesses()).rejects.toThrow(
'No hook native addon found for linux-x64',
);
});
it('on linux dedupes, drops non-positive/non-integer pids, and sorts', async () => {
setPlatform('linux', 'x64');
const nodePath = nativeCandidate('snowluma-linux-x64.node', 0);
allowExisting([nodePath]);
installDlopen(
fakeAddon({
getAllMainProcessAsync: [8, 0, 8, -1, 3.5, 2, Number.NaN, 2],
}),
);
const { listHookProcesses } = await loadInjector();
expect(await listHookProcesses()).toEqual([
{ pid: 2, name: 'qq', path: '' },
{ pid: 8, name: 'qq', path: '' },
]);
});
it('on win32 names processes QQ.exe', async () => {
setPlatform('win32', 'x64');
allowExisting([nativeCandidate('snowluma-win32-x64.node', 0)]);
installDlopen(fakeAddon({ getAllMainProcessAsync: [9] }));
const { listHookProcesses } = await loadInjector();
expect(await listHookProcesses()).toEqual([{ pid: 9, name: 'QQ.exe', path: '' }]);
});
});
describe('injectHookProcess', () => {
it('throws the missing-addon load error', async () => {
setPlatform('linux', 'x64');
const { injectHookProcess } = await loadInjector();
await expect(injectHookProcess(4242)).rejects.toThrow(
'No hook native addon found for linux-x64',
);
});
it('throws when the addon exists but the injectable is missing', async () => {
setPlatform('linux', 'x64');
allowExisting([nativeCandidate('snowluma-linux-x64.node', 0)]);
installDlopen(fakeAddon());
const { injectHookProcess } = await loadInjector();
await expect(injectHookProcess(4242)).rejects.toThrow('No hook so found for linux-x64');
});
it('awaits native asynchronous injection', async () => {
setPlatform('linux', 'x64');
const nodePath = nativeCandidate('snowluma-linux-x64.node', 0);
const soPath = nativeCandidate('snowluma-linux-x64.so', 0);
allowExisting([nodePath, soPath]);
const addon = fakeAddon({
loadModuleManualAsync: () => Promise.resolve(HANDLE),
});
installDlopen(addon);
const { injectHookProcess } = await loadInjector();
await expect(injectHookProcess(4242)).resolves.toEqual({
method: 'loadModuleManual',
handle: HANDLE,
});
expect(addon.loadModuleManualAsync).toHaveBeenCalledTimes(1);
expect(addon.loadModuleManualAsync).toHaveBeenCalledWith(4242, soPath);
});
it('injects snowluma-win32-x64.dll on win32 x64', async () => {
setPlatform('win32', 'x64');
const nodePath = nativeCandidate('snowluma-win32-x64.node', 0);
const dllPath = nativeCandidate('snowluma-win32-x64.dll', 0);
allowExisting([nodePath, dllPath]);
const addon = fakeAddon({ loadModuleManualAsync: async () => HANDLE });
installDlopen(addon);
const { injectHookProcess } = await loadInjector();
await expect(injectHookProcess(5150)).resolves.toEqual({
method: 'loadModuleManual',
handle: HANDLE,
});
expect(addon.loadModuleManualAsync).toHaveBeenCalledWith(5150, dllPath);
});
it('propagates a rejected loadModuleManual', async () => {
setPlatform('linux', 'x64');
allowExisting([
nativeCandidate('snowluma-linux-x64.node', 0),
nativeCandidate('snowluma-linux-x64.so', 0),
]);
installDlopen(
fakeAddon({
loadModuleManualAsync: () => Promise.reject(new Error('map failed')),
}),
);
const { injectHookProcess } = await loadInjector();
await expect(injectHookProcess(7)).rejects.toThrow('map failed');
});
});
describe('unloadHookProcess', () => {
it('propagates native unload rejection', async () => {
setPlatform('linux', 'x64');
allowExisting([nativeCandidate('snowluma-linux-x64.node', 0)]);
installDlopen(
fakeAddon({
unloadModuleManualAsync: async () => {
throw new Error('unload failed');
},
}),
);
const { unloadHookProcess } = await loadInjector();
await expect(unloadHookProcess(4242, HANDLE)).rejects.toThrow('unload failed');
});
it('throws the missing-addon load error', async () => {
setPlatform('linux', 'x64');
const { unloadHookProcess } = await loadInjector();
await expect(unloadHookProcess(4242, HANDLE)).rejects.toThrow(
'No hook native addon found for linux-x64',
);
});
it('forwards pid and handle to unloadModuleManual', async () => {
setPlatform('linux', 'x64');
allowExisting([nativeCandidate('snowluma-linux-x64.node', 0)]);
const addon = fakeAddon();
installDlopen(addon);
const { unloadHookProcess } = await loadInjector();
await expect(unloadHookProcess(4242, HANDLE)).resolves.toBeUndefined();
expect(addon.unloadModuleManualAsync).toHaveBeenCalledTimes(1);
expect(addon.unloadModuleManualAsync).toHaveBeenCalledWith(4242, HANDLE);
});
});
@@ -0,0 +1,30 @@
import { afterEach, describe, expect, it } from 'vitest';
import { QqHookClient } from '../src/qq-hook-client';
import { packetTimeoutMs } from '../src/packet-timeout';
const PACKET = 'SNOWLUMA_PACKET_TIMEOUT_MS';
const ACK = 'SNOWLUMA_HOOK_ACK_TIMEOUT_MS';
afterEach(() => {
delete process.env[PACKET];
delete process.env[ACK];
});
describe('packet and hook ack timeouts', () => {
it('keeps the 15 second packet default', () => {
expect(packetTimeoutMs({})).toBe(15_000);
});
it('reads SNOWLUMA_PACKET_TIMEOUT_MS', () => {
expect(packetTimeoutMs({ [PACKET]: '8000' })).toBe(8000);
expect(packetTimeoutMs({ [PACKET]: '0' })).toBe(15_000);
});
it('uses SNOWLUMA_HOOK_ACK_TIMEOUT_MS when the client is not given one', () => {
process.env[ACK] = '1200';
const client = new QqHookClient(1, { runtimeDir: '/tmp' });
expect(client.defaultAckTimeoutMs).toBe(1200);
const explicit = new QqHookClient(1, { runtimeDir: '/tmp', ackTimeoutMs: 50 });
expect(explicit.defaultAckTimeoutMs).toBe(50);
});
});
+476
View File
@@ -0,0 +1,476 @@
import { afterEach, describe, it, expect, vi } from 'vitest';
import { createLogger } from '@snowluma/common/logger';
import { PipeWatcher } from '../src/pipe-watcher';
import type { HookProcessBaseInfo } from '../src/injector';
function setupWatcher(initial: { processes: HookProcessBaseInfo[]; live: Set<number> }) {
let processes = initial.processes;
let live = initial.live;
const watcher = new PipeWatcher({
listProcesses: async () => processes,
// Defensively copy so the watcher can't mutate the test's reference.
listLivePipes: async () => new Set(live),
intervalMs: 60_000, // disable the internal timer; tests drive ticks manually
});
return {
watcher,
setProcesses: (p: HookProcessBaseInfo[]) => {
processes = p;
},
setLive: (s: Set<number>) => {
live = s;
},
};
}
type EventRow = [string, unknown];
function captureEvents(watcher: PipeWatcher): EventRow[] {
const events: EventRow[] = [];
watcher.on('process-discovered', (info) =>
events.push(['discovered', (info as HookProcessBaseInfo).pid]),
);
watcher.on('process-gone', (pid) => events.push(['gone', pid]));
watcher.on('pipe-up', (pid) => events.push(['up', pid]));
watcher.on('pipe-down', (pid) => events.push(['down', pid]));
return events;
}
describe('PipeWatcher', () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('waits for a fresh scan when another tick is already reading processes', async () => {
let release!: (processes: HookProcessBaseInfo[]) => void;
const pending = new Promise<HookProcessBaseInfo[]>((resolve) => {
release = resolve;
});
const latest = [{ pid: 1234, name: 'QQ.exe', path: '' }];
const listProcesses = vi.fn().mockReturnValueOnce(pending).mockResolvedValue(latest);
const watcher = new PipeWatcher({
listProcesses,
listLivePipes: async () => new Set([1234]),
});
const first = watcher.tickNow();
let completed = false;
const second = watcher.tickNow().then(() => {
completed = true;
});
await Promise.resolve();
expect(completed).toBe(false);
release([]);
await Promise.all([first, second]);
expect(listProcesses).toHaveBeenCalledTimes(2);
expect(watcher.isPipeLive(1234)).toBe(true);
watcher.dispose();
});
it.each(['processes', 'pipes'] as const)(
'discards a pending %s scan after stop',
async (stage) => {
let release!: () => void;
let entered!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const started = new Promise<void>((resolve) => {
entered = resolve;
});
const processes = [{ pid: 1234, name: 'QQ.exe', path: '' }];
const listProcesses = vi.fn(async () => {
if (stage === 'processes') {
entered();
await gate;
}
return processes;
});
const listLivePipes = vi.fn(async () => {
if (stage === 'pipes') {
entered();
await gate;
}
return new Set([1234]);
});
const watcher = new PipeWatcher({ listProcesses, listLivePipes });
const events = captureEvents(watcher);
const tick = vi.fn();
watcher.on('tick', tick);
const reading = watcher.start();
await started;
const queued = watcher.tickNow();
watcher.stop();
release();
await Promise.all([reading, queued]);
await watcher.tickNow();
watcher.wake();
expect(events).toEqual([]);
expect(tick).not.toHaveBeenCalled();
expect(watcher.isProcessAlive(1234)).toBe(false);
expect(listProcesses).toHaveBeenCalledOnce();
expect(listLivePipes).toHaveBeenCalledTimes(stage === 'pipes' ? 1 : 0);
watcher.dispose();
},
);
it('keeps a wake requested by a tick listener ahead of the regular interval', async () => {
vi.useFakeTimers();
const listProcesses = vi.fn(async () => []);
const watcher = new PipeWatcher({
listProcesses,
listLivePipes: async () => new Set(),
intervalMs: 60_000,
});
watcher.once('tick', () => watcher.wake());
await watcher.start();
await vi.advanceTimersByTimeAsync(1);
expect(listProcesses).toHaveBeenCalledTimes(2);
watcher.dispose();
});
it('reports a scheduled tick failure and continues polling', async () => {
vi.useFakeTimers();
const listProcesses = vi.fn(async () => []);
const log = createLogger('PipeWatcherTest');
const warn = vi.spyOn(log, 'warn').mockImplementation(() => {});
const watcher = new PipeWatcher({
listProcesses,
listLivePipes: async () => new Set(),
intervalMs: 250,
log,
});
try {
await watcher.start();
watcher.once('tick', () => {
throw new Error('subscriber failed');
});
await vi.advanceTimersByTimeAsync(250);
expect(warn).toHaveBeenCalledWith('poll failed: %s', 'subscriber failed');
await vi.advanceTimersByTimeAsync(250);
expect(listProcesses).toHaveBeenCalledTimes(3);
} finally {
watcher.dispose();
}
});
it('publishes the complete snapshot before notifying subscribers', async () => {
const ctx = setupWatcher({
processes: [{ pid: 1234, name: 'QQ.exe', path: '' }],
live: new Set([1234]),
});
const snapshots: boolean[][] = [];
const observe = () =>
snapshots.push([ctx.watcher.isProcessAlive(1234), ctx.watcher.isPipeLive(1234)]);
ctx.watcher.on('process-discovered', observe);
ctx.watcher.on('pipe-up', observe);
ctx.watcher.on('pipe-down', observe);
ctx.watcher.on('process-gone', observe);
await ctx.watcher.tickNow();
ctx.setProcesses([]);
ctx.setLive(new Set());
await ctx.watcher.tickNow();
expect(snapshots).toEqual([
[true, true],
[true, true],
[false, false],
[false, false],
]);
ctx.watcher.dispose();
});
it('stops emitting the current diff when a subscriber stops the watcher', async () => {
const ctx = setupWatcher({
processes: [{ pid: 1234, name: 'QQ.exe', path: '' }],
live: new Set([1234]),
});
ctx.watcher.on('process-discovered', () => ctx.watcher.stop());
const up = vi.fn();
const tick = vi.fn();
ctx.watcher.on('pipe-up', up);
ctx.watcher.on('tick', tick);
await ctx.watcher.tickNow();
expect(up).not.toHaveBeenCalled();
expect(tick).not.toHaveBeenCalled();
ctx.watcher.dispose();
});
it('passes the current process snapshot to pipe discovery', async () => {
const processes = [
{ pid: 1234, name: 'qq', path: '/opt/QQ/qq' },
{ pid: 5678, name: 'qq', path: '/opt/QQ/qq' },
];
let observed: readonly HookProcessBaseInfo[] | undefined;
const watcher = new PipeWatcher({
listProcesses: async () => processes,
listLivePipes: async (current) => {
observed = current;
return new Set();
},
intervalMs: 60_000,
});
await watcher.start();
expect(observed).toEqual(processes);
watcher.stop();
});
it('start() emits process-discovered + pipe-up for live PIDs on first tick', async () => {
const ctx = setupWatcher({
processes: [{ pid: 1234, name: 'QQ.exe', path: '' }],
live: new Set([1234]),
});
const events = captureEvents(ctx.watcher);
await ctx.watcher.start();
expect(events).toEqual([
['discovered', 1234],
['up', 1234],
]);
expect(ctx.watcher.isPipeLive(1234)).toBe(true);
expect(ctx.watcher.isProcessAlive(1234)).toBe(true);
ctx.watcher.stop();
});
it('pipe-down emitted when a previously-live pipe disappears', async () => {
const ctx = setupWatcher({
processes: [{ pid: 1234, name: 'QQ.exe', path: '' }],
live: new Set([1234]),
});
await ctx.watcher.start();
const events = captureEvents(ctx.watcher);
ctx.setLive(new Set());
await ctx.watcher.tickNow();
expect(events).toEqual([['down', 1234]]);
expect(ctx.watcher.isPipeLive(1234)).toBe(false);
ctx.watcher.stop();
});
it('process-gone + pipe-down both emitted (pipe-down first) when QQ.exe is killed with a live pipe', async () => {
const ctx = setupWatcher({
processes: [{ pid: 1234, name: 'QQ.exe', path: '' }],
live: new Set([1234]),
});
await ctx.watcher.start();
const events = captureEvents(ctx.watcher);
ctx.setProcesses([]);
ctx.setLive(new Set());
await ctx.watcher.tickNow();
expect(events).toEqual([
['down', 1234],
['gone', 1234],
]);
ctx.watcher.stop();
});
it('ignores live pipes whose process is not running', async () => {
const ctx = setupWatcher({
processes: [],
live: new Set([9999]),
});
const events = captureEvents(ctx.watcher);
await ctx.watcher.start();
expect(events).toEqual([]);
expect(ctx.watcher.isPipeLive(9999)).toBe(false);
ctx.watcher.stop();
});
it('emits pipe-up again after a pipe-down → pipe-up cycle', async () => {
const ctx = setupWatcher({
processes: [{ pid: 1234, name: 'QQ.exe', path: '' }],
live: new Set(),
});
await ctx.watcher.start();
const events = captureEvents(ctx.watcher);
ctx.setLive(new Set([1234]));
await ctx.watcher.tickNow();
expect(events).toEqual([['up', 1234]]);
ctx.setLive(new Set());
await ctx.watcher.tickNow();
expect(events).toEqual([
['up', 1234],
['down', 1234],
]);
ctx.setLive(new Set([1234]));
await ctx.watcher.tickNow();
expect(events).toEqual([
['up', 1234],
['down', 1234],
['up', 1234],
]);
ctx.watcher.stop();
});
it('does not double-emit on idempotent ticks (no state change)', async () => {
const ctx = setupWatcher({
processes: [{ pid: 1234, name: 'QQ.exe', path: '' }],
live: new Set([1234]),
});
await ctx.watcher.start();
const events = captureEvents(ctx.watcher);
await ctx.watcher.tickNow();
await ctx.watcher.tickNow();
expect(events).toEqual([]);
ctx.watcher.stop();
});
it('emits "tick" after every poll', async () => {
const ctx = setupWatcher({ processes: [], live: new Set() });
let ticks = 0;
ctx.watcher.on('tick', () => {
ticks++;
});
await ctx.watcher.start();
expect(ticks).toBe(1);
await ctx.watcher.tickNow();
expect(ticks).toBe(2);
ctx.watcher.stop();
});
it('wake() pulls the next tick forward', async () => {
const ctx = setupWatcher({
processes: [{ pid: 1234, name: 'QQ.exe', path: '' }],
live: new Set(),
});
await ctx.watcher.start();
const events = captureEvents(ctx.watcher);
// Wait on 'tick' rather than guessing how many event-loop turns
// setTimeout(0) needs (it's clamped to ~1ms and races setImmediate).
const nextTick = new Promise<void>((resolve) => ctx.watcher.once('tick', () => resolve()));
ctx.setLive(new Set([1234]));
ctx.watcher.wake();
await nextTick;
expect(events).toEqual([['up', 1234]]);
ctx.watcher.stop();
});
it('survives a throwing listProcesses without crashing the watcher', async () => {
let processes: HookProcessBaseInfo[] = [];
let live = new Set<number>();
let shouldThrow = true;
const watcher = new PipeWatcher({
listProcesses: async () => {
if (shouldThrow) {
throw new Error('boom');
}
return processes;
},
listLivePipes: async () => new Set(live),
intervalMs: 60_000,
});
await watcher.start();
expect(watcher.isProcessAlive(1234)).toBe(false);
shouldThrow = false;
processes = [{ pid: 1234, name: 'QQ.exe', path: '' }];
live = new Set([1234]);
const events = captureEvents(watcher);
await watcher.tickNow();
expect(events).toEqual([
['discovered', 1234],
['up', 1234],
]);
watcher.stop();
});
it('preserves live sessions when pipe discovery temporarily fails', async () => {
let processes = [{ pid: 1234, name: 'QQ.exe', path: '' }];
let failPipeDiscovery = false;
const watcher = new PipeWatcher({
listProcesses: async () => processes,
listLivePipes: async () => {
if (failPipeDiscovery) {
throw new Error('permission denied');
}
return new Set([1234]);
},
intervalMs: 60_000,
});
await watcher.start();
expect(watcher.isPipeLive(1234)).toBe(true);
const events = captureEvents(watcher);
failPipeDiscovery = true;
await watcher.tickNow();
expect(events).toEqual([]);
expect(watcher.isProcessAlive(1234)).toBe(true);
expect(watcher.isPipeLive(1234)).toBe(true);
processes = [];
await watcher.tickNow();
expect(events).toEqual([
['down', 1234],
['gone', 1234],
]);
expect(watcher.isProcessAlive(1234)).toBe(false);
expect(watcher.isPipeLive(1234)).toBe(false);
watcher.stop();
});
it('UNKNOWN (null) enumeration keeps prior state — no false process-gone (issue #158)', async () => {
let result: HookProcessBaseInfo[] | null = [{ pid: 1234, name: 'qq', path: '' }];
let live = new Set<number>([1234]);
const watcher = new PipeWatcher({
listProcesses: async () => result,
listLivePipes: async () => new Set(live),
intervalMs: 60_000,
});
await watcher.start();
expect(watcher.isProcessAlive(1234)).toBe(true);
expect(watcher.isPipeLive(1234)).toBe(true);
const events = captureEvents(watcher);
// Enumeration goes UNKNOWN (e.g. native /proc walk timed out during a QQ
// hot-update). Prior process + pipe state MUST be preserved — no teardown.
result = null;
await watcher.tickNow();
expect(events).toEqual([]);
expect(watcher.isProcessAlive(1234)).toBe(true);
expect(watcher.isPipeLive(1234)).toBe(true);
// Enumeration recovers with the same set → still no churn.
result = [{ pid: 1234, name: 'qq', path: '' }];
await watcher.tickNow();
expect(events).toEqual([]);
// A REAL disappearance (empty list, not null) still fires the teardown.
result = [];
live = new Set();
await watcher.tickNow();
expect(events).toEqual([
['down', 1234],
['gone', 1234],
]);
watcher.stop();
});
});
@@ -0,0 +1,120 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createProcessEnumerator } from '../src/process-enumerator';
import type { HookProcessBaseInfo } from '../src/injector';
const QQ = (pid: number): HookProcessBaseInfo => ({ pid, name: 'qq', path: '' });
function deferred() {
let resolve!: (processes: HookProcessBaseInfo[]) => void;
let reject!: (error: Error) => void;
const promise = new Promise<HookProcessBaseInfo[]>((done, fail) => {
resolve = done;
reject = fail;
});
return { promise, resolve, reject };
}
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
describe('process enumeration', () => {
it('shares one native request between concurrent callers', async () => {
const request = deferred();
const listProcesses = vi.fn(() => request.promise);
const enumerator = createProcessEnumerator({ listProcesses });
const first = enumerator.enumerate();
expect(enumerator.enumerate()).toBe(first);
request.resolve([QQ(1), QQ(2)]);
expect(await first).toEqual([QQ(1), QQ(2)]);
expect(listProcesses).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
enumerator.dispose();
});
it('keeps timed-out native work in flight until it actually finishes', async () => {
const request = deferred();
const listProcesses = vi.fn(() => request.promise).mockResolvedValueOnce([QQ(1)]);
const enumerator = createProcessEnumerator({ listProcesses, timeoutMs: 100 });
expect(await enumerator.enumerate()).toEqual([QQ(1)]);
const stalled = enumerator.enumerate();
await vi.advanceTimersByTimeAsync(100);
expect(await stalled).toBeNull();
for (let i = 0; i < 10; i++) {
expect(await enumerator.enumerate()).toBeNull();
}
expect(listProcesses).toHaveBeenCalledTimes(2);
request.resolve([QQ(2)]);
await vi.advanceTimersByTimeAsync(0);
listProcesses.mockResolvedValueOnce([QQ(3)]);
expect(await enumerator.enumerate()).toEqual([QQ(3)]);
expect(await stalled).toBeNull();
enumerator.dispose();
});
it.each(['throw', 'reject'] as const)(
'keeps a %s unknown and allows a fresh request',
async (kind) => {
const listProcesses = vi.fn<() => Promise<HookProcessBaseInfo[]>>(() => {
if (kind === 'throw') {
throw new Error('process table unavailable');
}
return Promise.reject(new Error('process table unavailable'));
});
const enumerator = createProcessEnumerator({ listProcesses });
expect(await enumerator.enumerate()).toBeNull();
expect(vi.getTimerCount()).toBe(0);
listProcesses.mockResolvedValueOnce([]);
expect(await enumerator.enumerate()).toEqual([]);
enumerator.dispose();
},
);
it('handles a rejection after timeout and recovers on the next poll', async () => {
const request = deferred();
const listProcesses = vi.fn(() => request.promise);
const enumerator = createProcessEnumerator({ listProcesses, timeoutMs: 100 });
const pending = enumerator.enumerate();
await vi.advanceTimersByTimeAsync(100);
expect(await pending).toBeNull();
request.reject(new Error('late failure'));
await vi.advanceTimersByTimeAsync(0);
listProcesses.mockResolvedValueOnce([QQ(4)]);
expect(await enumerator.enumerate()).toEqual([QQ(4)]);
enumerator.dispose();
});
it('settles disposal immediately without accepting late native results', async () => {
const request = deferred();
const listProcesses = vi.fn(() => request.promise);
const enumerator = createProcessEnumerator({ listProcesses });
const pending = enumerator.enumerate();
await Promise.resolve();
enumerator.dispose();
enumerator.dispose();
expect(await pending).toBeNull();
expect(vi.getTimerCount()).toBe(0);
request.resolve([QQ(1)]);
expect(await enumerator.enumerate()).toBeNull();
expect(listProcesses).toHaveBeenCalledTimes(1);
});
it('never calls the lister after disposal', async () => {
const listProcesses = vi.fn(async () => [QQ(1)]);
const enumerator = createProcessEnumerator({ listProcesses });
enumerator.dispose();
expect(await enumerator.enumerate()).toBeNull();
expect(listProcesses).not.toHaveBeenCalled();
});
it('does not start queued native work when disposed in the same tick', async () => {
const listProcesses = vi.fn(async () => [QQ(1)]);
const enumerator = createProcessEnumerator({ listProcesses });
const pending = enumerator.enumerate();
enumerator.dispose();
expect(await pending).toBeNull();
expect(listProcesses).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,142 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { mkdtemp, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { SqliteDatabase } from '@snowluma/sqlite';
import { isQqProcessName, getDatabaseHolders } from '../src/qq-account/database-holders';
const native = vi.hoisted(() => ({ addon: vi.fn<() => unknown>(() => null) }));
vi.mock('../src/injector', () => ({ getNativeHookAddon: native.addon }));
const IS_LINUX = process.platform === 'linux';
const ORIGINAL_PLATFORM = process.platform;
/** Run `getDatabaseHolders` as if the host were Windows, with a stubbed addon. */
async function asWindows<T>(addon: unknown, run: () => Promise<T>): Promise<T> {
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
native.addon.mockReturnValue(addon);
try {
return await run();
} finally {
Object.defineProperty(process, 'platform', {
value: ORIGINAL_PLATFORM,
configurable: true,
});
}
}
afterEach(() => {
native.addon.mockReset();
native.addon.mockReturnValue(null);
});
describe('database holder probe', () => {
it.runIf(IS_LINUX)('reports nothing for a file that is not there', async () => {
const missing = path.join(os.tmpdir(), `snowluma-absent-${process.pid}.db`);
expect(await getDatabaseHolders(missing)).toEqual([]);
});
it.runIf(IS_LINUX)('finds the process holding a database', async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'snowluma-lock-'));
const file = path.join(directory, 'nt_msg.db');
const db = new SqliteDatabase(file);
try {
// A rollback-journal write transaction takes a POSIX lock on the
// database file itself, which is exactly what a signed-in client
// holds on the account's nt_msg.db.
await db.exec('PRAGMA journal_mode = DELETE');
await db.exec('CREATE TABLE t(value TEXT)');
await db.exec('BEGIN IMMEDIATE');
const holders = await getDatabaseHolders(file);
const self = holders.find((holder) => holder.pid === process.pid);
expect(self).toBeDefined();
// The holder is named from /proc/<pid>/comm, and that name is what
// keeps a holder that is not QQ from being mistaken for one.
expect(self!.name.length).toBeGreaterThan(0);
expect(isQqProcessName(self!.name)).toBe(false);
await db.exec('ROLLBACK');
expect(
(await getDatabaseHolders(file)).some((holder) => holder.pid === process.pid),
).toBe(false);
} finally {
await db.close();
await rm(directory, { recursive: true, force: true });
}
});
});
describe('Windows holder probe', () => {
it('propagates rejected native queries instead of treating them as no login', async () => {
await expect(
asWindows(
{
getFileHoldersAsync: async () => {
throw new Error('RmGetList failed: 5');
},
},
() => getDatabaseHolders('C:\\nt_msg.db'),
),
).rejects.toThrow('RmGetList failed: 5');
});
it('reports the holders the addon lists', async () => {
const holders = await asWindows(
{
getFileHoldersAsync: async () => [
{ pid: 4242, name: 'QQ.exe' },
{ pid: 7, name: 'weq' },
],
},
() => getDatabaseHolders('C:\\Tencent Files\\10001\\nt_qq\\nt_db\\nt_msg.db'),
);
expect(holders).toEqual([
{ pid: 4242, name: 'QQ.exe' },
{ pid: 7, name: 'weq' },
]);
});
it.each([[{ pid: 0, name: 'QQ' }], [{ pid: 9 }], [null], 'not-an-array'])(
'reports malformed native results: %j',
async (result) => {
await expect(
asWindows({ getFileHoldersAsync: async () => result }, () =>
getDatabaseHolders('C:\\nt_msg.db'),
),
).rejects.toThrow('Invalid database holders');
},
);
it('accepts an empty holder list', async () => {
expect(
await asWindows({ getFileHoldersAsync: async () => [] }, () =>
getDatabaseHolders('C:\\nt_msg.db'),
),
).toEqual([]);
});
it.each([null, {}])('reports an unavailable native probe: %j', async (addon) => {
await expect(asWindows(addon, () => getDatabaseHolders('C:\\nt_msg.db'))).rejects.toThrow(
'requires a hook addon with getFileHoldersAsync',
);
});
});
describe('QQ process names', () => {
it('accepts the name each platform reports for the client', () => {
expect(isQqProcessName('QQ')).toBe(true);
expect(isQqProcessName('QQ.exe')).toBe(true);
expect(isQqProcessName(' qq ')).toBe(true);
});
it('rejects other holders', () => {
expect(isQqProcessName('node')).toBe(false);
expect(isQqProcessName('qqmusic')).toBe(false);
expect(isQqProcessName('')).toBe(false);
});
});
@@ -0,0 +1,241 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { spawn } from 'node:child_process';
import { mkdtemp, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { getLogLevel, setLogLevel, subscribeLogs, type LogEntry } from '@snowluma/common/logger';
const mocks = vi.hoisted(() => ({
readLocalAccounts: vi.fn(),
getDatabaseHolders: vi.fn(),
}));
// The account list and the holder probe are covered on their own; this file is
// about how the two are joined.
vi.mock('../src/qq-account/login-db', () => ({ readLocalAccounts: mocks.readLocalAccounts }));
vi.mock('../src/qq-account/database-holders', async (importOriginal) => ({
...(await importOriginal<typeof import('../src/qq-account/database-holders')>()),
getDatabaseHolders: mocks.getDatabaseHolders,
}));
import { detectAccountLogins, detectAccountLoginsForPids } from '../src/qq-account/detect';
import { loginDbPaths, ntMsgDbPath } from '../src/qq-account/paths';
const ALICE = { uin: '10001', uid: 'u_alice', nickname: 'Alice' };
const BOB = { uin: '10002', uid: 'u_bob', nickname: 'Bob' };
const roots: string[] = [];
const children: Array<ReturnType<typeof spawn>> = [];
afterEach(async () => {
mocks.readLocalAccounts.mockReset();
mocks.getDatabaseHolders.mockReset();
for (const child of children.splice(0)) {
child.kill();
}
for (const root of roots.splice(0)) {
await rm(root, { recursive: true, force: true });
}
});
async function makeRoot(): Promise<string> {
const root = await mkdtemp(path.join(os.tmpdir(), 'snowluma-detect-'));
roots.push(root);
return root;
}
/**
* A live process whose `/proc/<pid>/environ` carries `HOME` (and any extra
* variables given).
*
* `XDG_CONFIG_HOME` is dropped unless the caller sets it: it outranks `HOME`
* when deriving the data root, and a CI runner exports its own, which would
* otherwise decide the result instead of the HOME under test.
*
* Resolves only once the child has run past `execve`, because `/proc/<pid>`
* still shows the parent's environment before that.
*/
function spawnWithHome(home: string, extra: Record<string, string> = {}): Promise<number> {
return new Promise((resolve, reject) => {
const env: NodeJS.ProcessEnv = { ...process.env };
delete env.XDG_CONFIG_HOME;
Object.assign(env, { HOME: home }, extra);
const child = spawn(
process.execPath,
['-e', 'console.log("ready"); setTimeout(() => {}, 30000)'],
{ env, stdio: ['ignore', 'pipe', 'ignore'] },
);
children.push(child);
child.stdout!.once('data', () => resolve(child.pid!));
child.once('error', reject);
});
}
describe('local account detection', () => {
it('labels each account with the process holding its database', async () => {
const root = await makeRoot();
mocks.readLocalAccounts.mockResolvedValue({ accounts: [ALICE, BOB], failures: [] });
mocks.getDatabaseHolders.mockImplementation(async (dbPath: string) => {
if (dbPath === ntMsgDbPath(root, ALICE)) {
return [{ pid: 4242, name: 'qq' }];
}
// A holder that is not the client must not be mistaken for one.
return [{ pid: 999, name: 'node' }];
});
await expect(detectAccountLogins([root])).resolves.toEqual([
{ ...ALICE, pid: 4242 },
{ ...BOB, pid: null },
]);
});
it('merges the accounts of every root instead of stopping at the first', async () => {
const own = await makeRoot();
const extra = await makeRoot();
mocks.readLocalAccounts.mockImplementation(async (root: string) =>
root === own ? { accounts: [ALICE], failures: [] } : { accounts: [BOB], failures: [] },
);
mocks.getDatabaseHolders.mockImplementation(async (dbPath: string) =>
dbPath === ntMsgDbPath(extra, BOB) ? [{ pid: 5151, name: 'qq' }] : [],
);
await expect(detectAccountLogins([own, extra])).resolves.toEqual([
{ ...ALICE, pid: null },
{ ...BOB, pid: 5151 },
]);
// An account held under an instance's own HOME is still labelled, which
// is what a single-root lookup cannot do.
expect(mocks.getDatabaseHolders).toHaveBeenCalledWith(ntMsgDbPath(extra, BOB));
});
it('keeps account records with the same uin scoped to their own root', async () => {
const first = await makeRoot();
const second = await makeRoot();
mocks.readLocalAccounts.mockImplementation(async (root: string) =>
root === first
? { accounts: [ALICE], failures: [] }
: { accounts: [{ ...ALICE, nickname: 'Stale' }], failures: [] },
);
mocks.getDatabaseHolders.mockImplementation(async (file: string) =>
file === ntMsgDbPath(second, ALICE) ? [{ pid: 5151, name: 'qq' }] : [],
);
await expect(detectAccountLogins([first, second])).resolves.toEqual([
{ ...ALICE, pid: null },
{ ...ALICE, nickname: 'Stale', pid: 5151 },
]);
});
it('reports nothing when there are no roots', async () => {
await expect(detectAccountLogins([])).resolves.toEqual([]);
expect(mocks.readLocalAccounts).not.toHaveBeenCalled();
});
it('logs a named reason when a login.db is present but unreadable', async () => {
const root = await makeRoot();
const entries: LogEntry[] = [];
const previousLevel = getLogLevel();
setLogLevel('debug');
const unsubscribe = subscribeLogs((entry) => entries.push(entry));
try {
mocks.readLocalAccounts.mockResolvedValue({
accounts: [],
failures: [
{
path: loginDbPaths(root)[0]!,
reason: 'undecryptable',
},
],
});
mocks.getDatabaseHolders.mockResolvedValue([]);
// An unreadable login.db must not look like "no accounts": an
// operator reading the log has to see why nothing was reported.
await detectAccountLogins([root]);
} finally {
unsubscribe();
setLogLevel(previousLevel);
}
const warned = entries.filter(
(entry) => entry.level === 'warn' && entry.scope === 'QqAccount',
);
expect(warned).toHaveLength(1);
expect(warned[0]!.message).toContain('login.db unreadable');
expect(warned[0]!.message).toContain('undecryptable');
});
});
describe.runIf(process.platform === 'linux')('per-process data roots', () => {
it('reads each pid against the HOME that process runs under', async () => {
const home = await makeRoot();
const pid = await spawnWithHome(home);
mocks.readLocalAccounts.mockResolvedValue({ accounts: [], failures: [] });
mocks.getDatabaseHolders.mockResolvedValue([]);
await detectAccountLoginsForPids([pid]);
// `<HOME>/.config/QQ`, from the child's environment rather than ours.
expect(mocks.readLocalAccounts).toHaveBeenCalledWith(path.join(home, '.config', 'QQ'));
});
it('asks each root once when several processes share a HOME', async () => {
const home = await makeRoot();
const pids = [await spawnWithHome(home), await spawnWithHome(home)];
mocks.readLocalAccounts.mockResolvedValue({ accounts: [], failures: [] });
mocks.getDatabaseHolders.mockResolvedValue([]);
await detectAccountLoginsForPids(pids);
expect(mocks.readLocalAccounts).toHaveBeenCalledTimes(1);
});
it('honours XDG_CONFIG_HOME of the process over its HOME', async () => {
const home = await makeRoot();
const configHome = await makeRoot();
const pid = await spawnWithHome(home, { XDG_CONFIG_HOME: configHome });
mocks.readLocalAccounts.mockResolvedValue({ accounts: [], failures: [] });
mocks.getDatabaseHolders.mockResolvedValue([]);
await detectAccountLoginsForPids([pid]);
// The XDG variable outranks HOME, as it does for the client itself.
expect(mocks.readLocalAccounts).toHaveBeenCalledWith(path.join(configHome, 'QQ'));
});
it('keeps each process on its own root when the HOMEs differ', async () => {
const first = await makeRoot();
const second = await makeRoot();
const pids = [await spawnWithHome(first), await spawnWithHome(second)];
mocks.readLocalAccounts.mockResolvedValue({ accounts: [], failures: [] });
mocks.getDatabaseHolders.mockResolvedValue([]);
await detectAccountLoginsForPids(pids);
expect(mocks.readLocalAccounts).toHaveBeenCalledWith(path.join(first, '.config', 'QQ'));
expect(mocks.readLocalAccounts).toHaveBeenCalledWith(path.join(second, '.config', 'QQ'));
});
it('does not substitute another HOME when the pid is gone', async () => {
const home = await makeRoot();
const pid = await spawnWithHome(home);
mocks.readLocalAccounts.mockResolvedValue({ accounts: [], failures: [] });
mocks.getDatabaseHolders.mockResolvedValue([]);
// The child exits, so its environment is no longer readable.
await new Promise<void>((resolve) => {
const child = children[children.length - 1]!;
child.once('exit', () => resolve());
child.kill();
});
await detectAccountLoginsForPids([pid]);
expect(mocks.readLocalAccounts).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,195 @@
/**
* Encryption helpers for the account-detection tests.
*
* The shipped code only decrypts, so producing an encrypted fixture means
* mirroring SQLCipher's page layout here: `[client header][page 1]…` with each
* page ending in `IV || page HMAC`, and a `-wal` whose frame header stays
* plaintext while the page inside it is encrypted.
*/
import { createCipheriv, createHmac, pbkdf2Sync, randomBytes } from 'node:crypto';
import type { KdfHmacAlgorithm, PageHmacAlgorithm } from '../src/qq-account/sqlcipher';
export interface FixtureAlgorithm {
pageHmac: PageHmacAlgorithm;
kdfHmac: KdfHmacAlgorithm;
}
export const FIXTURE_PASSPHRASE = 'BD156D6710D54D8782F4';
export const FIXTURE_PAGE_SIZE = 4096;
export const FIXTURE_HEADER_SIZE = 1024;
export const FIXTURE_WAL_HEADER_SIZE = 32;
export const FIXTURE_WAL_FRAME_HEADER_SIZE = 24;
const DIGEST_BYTES: Record<PageHmacAlgorithm, number> = {
none: 0,
sha1: 20,
sha256: 32,
sha512: 64,
};
const KDF_ITERATIONS = 4000;
const HMAC_KEY_ITERATIONS = 2;
const HMAC_SALT_MASK = 0x3a;
export function reserveBytes(algorithm: FixtureAlgorithm): number {
return Math.ceil((16 + DIGEST_BYTES[algorithm.pageHmac]) / 16) * 16;
}
/** Encrypt a plaintext SQLite image the way QQ NT stores it. */
export function encryptImage(
plain: Buffer,
algorithm: FixtureAlgorithm,
passphrase = FIXTURE_PASSPHRASE,
salt = randomBytes(16),
): Buffer {
const pageCount = plain.length / FIXTURE_PAGE_SIZE;
const key = pbkdf2Sync(passphrase, salt, KDF_ITERATIONS, 32, algorithm.kdfHmac);
const output = Buffer.alloc(FIXTURE_HEADER_SIZE + plain.length);
for (let index = 0; index < pageCount; index++) {
const pageNumber = index + 1;
const skip = pageNumber === 1 ? 16 : 0;
const body = plain.subarray(
index * FIXTURE_PAGE_SIZE + skip,
(index + 1) * FIXTURE_PAGE_SIZE,
);
if (pageNumber === 1) {
// Page 1 stores the KDF salt where SQLite keeps its magic.
salt.copy(output, FIXTURE_HEADER_SIZE);
}
encryptPageInto(
output,
FIXTURE_HEADER_SIZE + index * FIXTURE_PAGE_SIZE,
body,
pageNumber,
key,
salt,
algorithm,
);
}
return output;
}
/** Encrypt the pages of a real (plaintext) WAL, recomputing the frame checksums. */
export function encryptWal(
plainWal: Buffer,
encryptedImage: Buffer,
algorithm: FixtureAlgorithm,
passphrase = FIXTURE_PASSPHRASE,
): Buffer {
const salt = encryptedImage.subarray(FIXTURE_HEADER_SIZE, FIXTURE_HEADER_SIZE + 16);
const key = pbkdf2Sync(passphrase, salt, KDF_ITERATIONS, 32, algorithm.kdfHmac);
const frameSize = FIXTURE_WAL_FRAME_HEADER_SIZE + FIXTURE_PAGE_SIZE;
const output = Buffer.alloc(plainWal.length);
plainWal.copy(output, 0, 0, FIXTURE_WAL_HEADER_SIZE);
let running = walChecksum([0, 0], [output.subarray(0, FIXTURE_WAL_HEADER_SIZE - 8)], true);
for (
let offset = FIXTURE_WAL_HEADER_SIZE;
offset + frameSize <= plainWal.length;
offset += frameSize
) {
const frameHeader = plainWal.subarray(offset, offset + FIXTURE_WAL_FRAME_HEADER_SIZE);
const pageNumber = frameHeader.readUInt32BE(0);
const skip = pageNumber === 1 ? 16 : 0;
const page = plainWal.subarray(offset + FIXTURE_WAL_FRAME_HEADER_SIZE, offset + frameSize);
encryptPageInto(
output,
offset + FIXTURE_WAL_FRAME_HEADER_SIZE,
page.subarray(skip),
pageNumber,
key,
salt,
algorithm,
);
const stored = output.subarray(offset, offset + FIXTURE_WAL_FRAME_HEADER_SIZE);
frameHeader.copy(stored, 0, 0, 8);
running = walChecksum(
running,
[
stored.subarray(0, 8),
output.subarray(offset + FIXTURE_WAL_FRAME_HEADER_SIZE, offset + frameSize),
],
true,
);
stored.writeUInt32BE(running[0], 16);
stored.writeUInt32BE(running[1], 20);
}
return output;
}
/**
* SQLite's rolling WAL checksum, as the fixture needs it to rebuild frame
* checksums — and to prove the shipped implementation agrees with what SQLite
* itself wrote in a real `-wal`.
*/
export function walChecksum(
seed: [number, number],
chunks: readonly Buffer[],
littleEndian: boolean,
): [number, number] {
let first = seed[0];
let second = seed[1];
for (const chunk of chunks) {
for (let offset = 0; offset + 8 <= chunk.length; offset += 8) {
const high = littleEndian ? chunk.readUInt32LE(offset) : chunk.readUInt32BE(offset);
const low = littleEndian
? chunk.readUInt32LE(offset + 4)
: chunk.readUInt32BE(offset + 4);
first = (first + high + second) >>> 0;
second = (second + low + first) >>> 0;
}
}
return [first, second];
}
function encryptPageInto(
output: Buffer,
pageOffset: number,
body: Buffer,
pageNumber: number,
key: Buffer,
salt: Buffer,
algorithm: FixtureAlgorithm,
): void {
// Page 1 stores the KDF salt where SQLite keeps its magic, so its ciphertext
// starts one salt past the page boundary.
const offset = pageOffset + (pageNumber === 1 ? 16 : 0);
const reserve = reserveBytes(algorithm);
const cipherLength = body.length - reserve;
const iv = randomBytes(16);
const cipher = createCipheriv('aes-256-cbc', key, iv);
cipher.setAutoPadding(false);
const ciphertext = Buffer.concat([
cipher.update(body.subarray(0, cipherLength)),
cipher.final(),
]);
ciphertext.copy(output, offset);
iv.copy(output, offset + cipherLength);
const digestBytes = DIGEST_BYTES[algorithm.pageHmac];
if (digestBytes === 0) {
return;
}
const hmacSalt = Buffer.from(salt.map((byte) => byte ^ HMAC_SALT_MASK));
const hmacKey = pbkdf2Sync(key, hmacSalt, HMAC_KEY_ITERATIONS, 32, algorithm.kdfHmac);
const pageNumberBytes = Buffer.alloc(4);
pageNumberBytes.writeUInt32LE(pageNumber);
const digest = createHmac(algorithm.pageHmac, hmacKey)
.update(ciphertext)
.update(iv)
.update(pageNumberBytes)
.digest();
digest.copy(output, offset + cipherLength + 16);
}
@@ -0,0 +1,246 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { SqliteDatabase } from '@snowluma/sqlite';
const mocks = vi.hoisted(() => ({
detectAlgorithm: vi.fn(),
decryptDatabase: vi.fn(),
}));
// The page crypto is covered on its own; account reading stands on top of it, so
// the fixtures below hand over plaintext images and the decrypt step is stubbed
// to pass them through.
vi.mock('../src/qq-account/sqlcipher', () => ({
detectAlgorithm: mocks.detectAlgorithm,
decryptDatabase: mocks.decryptDatabase,
}));
vi.mock('../src/qq-account/paths', () => ({
loginDbPaths: (root: string) => [path.join(root, PRIMARY), path.join(root, SECONDARY)],
}));
import { readLocalAccounts } from '../src/qq-account/login-db';
const PRIMARY = path.join('global', 'nt_db', 'login.db');
const SECONDARY = path.join('nt_qq', 'global', 'nt_db', 'login.db');
/** Write a plaintext database with QQ's account table at `file`. */
async function writeLoginDb(
file: string,
rows: ReadonlyArray<readonly [string, string, string]>,
): Promise<void> {
await mkdir(path.dirname(file), { recursive: true });
await rm(file, { force: true });
await rm(`${file}-wal`, { force: true });
const db = new SqliteDatabase(file);
try {
await db.exec('CREATE TABLE login_table("1000" TEXT, "1001" TEXT, "1007" TEXT)');
for (const [uin, uid, nickname] of rows) {
await db.prepare('INSERT INTO login_table VALUES (?, ?, ?)').run(uin, uid, nickname);
}
} finally {
await db.close();
}
}
async function makeRoot(
rows: ReadonlyArray<readonly [string, string, string]>,
secondaryRows: ReadonlyArray<readonly [string, string, string]> = [],
): Promise<string> {
const root = await mkdtemp(path.join(os.tmpdir(), 'snowluma-login-'));
await writeLoginDb(path.join(root, PRIMARY), rows);
if (secondaryRows.length > 0) {
await writeLoginDb(path.join(root, SECONDARY), secondaryRows);
}
return root;
}
const roots: string[] = [];
beforeEach(() => {
mocks.detectAlgorithm.mockReturnValue({ pageHmac: 'sha1', kdfHmac: 'sha512' });
mocks.decryptDatabase.mockImplementation((image: Buffer) => ({ image, walFrames: 0 }));
});
afterEach(async () => {
mocks.detectAlgorithm.mockReset();
mocks.decryptDatabase.mockReset();
for (const root of roots.splice(0)) {
await rm(root, { recursive: true, force: true });
}
});
async function trackedRoot(
rows: ReadonlyArray<readonly [string, string, string]>,
secondaryRows: ReadonlyArray<readonly [string, string, string]> = [],
): Promise<string> {
const root = await makeRoot(rows, secondaryRows);
roots.push(root);
return root;
}
describe('local account list', () => {
it('reads the accounts QQ cached locally', async () => {
const root = await trackedRoot([['10001', 'u_a', 'Alice']]);
await expect(readLocalAccounts(root)).resolves.toEqual({
accounts: [{ uin: '10001', uid: 'u_a', nickname: 'Alice' }],
failures: [],
});
});
it('merges both login.db locations, letting the primary win on a clash', async () => {
const root = await trackedRoot(
[
['10001', 'u_a', 'Alice'],
['10002', 'u_b', 'Bob'],
],
[
['10001', 'u_a_stale', 'AliceStale'],
['10003', 'u_c', 'Carol'],
],
);
expect(await readLocalAccounts(root)).toEqual({
accounts: [
{ uin: '10001', uid: 'u_a', nickname: 'Alice' },
{ uin: '10002', uid: 'u_b', nickname: 'Bob' },
{ uin: '10003', uid: 'u_c', nickname: 'Carol' },
],
failures: [],
});
});
it('re-decrypts only when the database or its sidecar changes', async () => {
const root = await trackedRoot([['10001', 'u_a', 'Alice']]);
const file = path.join(root, PRIMARY);
await readLocalAccounts(root);
await readLocalAccounts(root);
expect(mocks.decryptDatabase).toHaveBeenCalledTimes(1);
// A login that just happened rewrites the database; the new rows have to
// show up instead of the cached list.
await writeLoginDb(file, [
['10001', 'u_a', 'Alice'],
['10002', 'u_b', 'Bob'],
]);
await utimes(file, new Date(Date.now() + 60_000), new Date(Date.now() + 60_000));
expect((await readLocalAccounts(root)).accounts).toHaveLength(2);
expect(mocks.decryptDatabase).toHaveBeenCalledTimes(2);
// The sidecar counts as a change too: a batch committed to the WAL only
// leaves login.db untouched.
await writeFile(`${file}-wal`, Buffer.alloc(0));
await readLocalAccounts(root);
expect(mocks.decryptDatabase).toHaveBeenCalledTimes(3);
});
it('shares concurrent reads of the same database image', async () => {
const root = await trackedRoot([['10001', 'u_a', 'Alice']]);
const reports = await Promise.all([readLocalAccounts(root), readLocalAccounts(root)]);
expect(reports[0]).toEqual(reports[1]);
expect(mocks.decryptDatabase).toHaveBeenCalledTimes(1);
});
it('reports an unreadable WAL instead of reading a stale base image', async () => {
const root = await trackedRoot([['10001', 'u_a', 'Alice']]);
const file = path.join(root, PRIMARY);
await mkdir(`${file}-wal`);
const report = await readLocalAccounts(root);
expect(report.accounts).toEqual([]);
expect(report.failures).toMatchObject([{ path: file, reason: 'read-failed' }]);
expect(report.failures[0]!.error).toBeDefined();
});
it('reports missing schema columns instead of accepting quoted column names as identities', async () => {
const root = await trackedRoot([['10001', 'u_a', 'Alice']]);
const file = path.join(root, PRIMARY);
const db = new SqliteDatabase(file);
try {
await db.exec('ALTER TABLE login_table DROP COLUMN "1001"');
} finally {
await db.close();
}
const report = await readLocalAccounts(root);
expect(report.accounts).toEqual([]);
expect(report.failures).toMatchObject([{ path: file, reason: 'query-failed' }]);
expect(report.failures[0]!.error).toBeDefined();
});
it('reports no accounts when the file cannot be decrypted, and retries later', async () => {
const root = await trackedRoot([['10001', 'u_a', 'Alice']]);
mocks.detectAlgorithm.mockReturnValue(null);
await expect(readLocalAccounts(root)).resolves.toEqual({
accounts: [],
failures: [{ path: path.join(root, PRIMARY), reason: 'undecryptable' }],
});
mocks.detectAlgorithm.mockReturnValue({ pageHmac: 'sha1', kdfHmac: 'sha512' });
const file = path.join(root, PRIMARY);
await utimes(file, new Date(Date.now() + 60_000), new Date(Date.now() + 60_000));
await expect(readLocalAccounts(root)).resolves.toEqual({
accounts: [{ uin: '10001', uid: 'u_a', nickname: 'Alice' }],
failures: [],
});
});
it('names an unreadable login.db instead of reporting "no accounts"', async () => {
const root = await trackedRoot([['10001', 'u_a', 'Alice']]);
mocks.detectAlgorithm.mockReturnValue(null);
// A login.db that cannot be decrypted is not the same as a machine with
// no accounts, and the caller has to be able to tell them apart.
await expect(readLocalAccounts(root)).resolves.toEqual({
accounts: [],
failures: [{ path: path.join(root, PRIMARY), reason: 'undecryptable' }],
});
});
it('keeps an unreadable sidecar from hiding the accounts a good database has', async () => {
const root = await trackedRoot([['10001', 'u_a', 'Alice']], [['10002', 'u_b', 'Bob']]);
const secondary = path.join(root, SECONDARY);
const bogus = Buffer.alloc(8192, 7);
mocks.detectAlgorithm.mockImplementation((image: Buffer) =>
image[0] === 7 ? null : { pageHmac: 'sha1', kdfHmac: 'sha512' },
);
await writeFile(secondary, bogus);
const report = await readLocalAccounts(root);
expect(report.accounts.map((account) => account.uin)).toEqual(['10001']);
expect(report.failures).toEqual([{ path: secondary, reason: 'undecryptable' }]);
});
it('skips rows that carry no usable identity', async () => {
const root = await trackedRoot([
['10001', 'u_a', 'Alice'],
['10002', '', 'Broken'],
['', 'u_d', 'Nameless'],
['../10001', 'u_e', 'Invalid UIN'],
]);
await expect(readLocalAccounts(root)).resolves.toEqual({
accounts: [{ uin: '10001', uid: 'u_a', nickname: 'Alice' }],
failures: [],
});
});
it('reports no accounts when login.db is missing', async () => {
const root = await mkdtemp(path.join(os.tmpdir(), 'snowluma-login-'));
roots.push(root);
// Neither candidate file exists: nothing cached, and nothing to report.
await expect(readLocalAccounts(root)).resolves.toEqual({ accounts: [], failures: [] });
});
});
@@ -0,0 +1,51 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import path from 'node:path';
import { qqDataRoots, qqDataRootsForPid } from '../src/qq-account/paths';
const files = vi.hoisted(() => ({ read: vi.fn() }));
vi.mock('node:fs', () => ({ readFileSync: files.read }));
const platform = process.platform;
beforeEach(() => {
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
});
afterEach(() => {
Object.defineProperty(process, 'platform', { value: platform, configurable: true });
files.read.mockReset();
});
describe('account data root isolation', () => {
it('uses the client HOME and honours its XDG configuration', () => {
files.read.mockReturnValue('HOME=/clients/a\0IGNORED=value\0');
expect(qqDataRootsForPid(42)).toEqual([path.join('/clients/a', '.config', 'QQ')]);
files.read.mockReturnValue('HOME=/clients/a\0XDG_CONFIG_HOME=/clients/config\0');
expect(qqDataRootsForPid(42)).toEqual([path.join('/clients/config', 'QQ')]);
});
it('does not guess a HOME when the client environment has none', () => {
files.read.mockReturnValue('OTHER=value\0');
expect(qqDataRootsForPid(42)).toEqual([]);
});
it('reports no root for an exited process', () => {
files.read.mockImplementation(() => {
throw Object.assign(new Error('gone'), { code: 'ENOENT' });
});
expect(qqDataRootsForPid(42)).toEqual([]);
});
it('surfaces permission failures instead of reading another user directory', () => {
const error = Object.assign(new Error('denied'), { code: 'EACCES' });
files.read.mockImplementation(() => {
throw error;
});
expect(() => qqDataRootsForPid(42)).toThrow(error);
});
it('uses only the configured Windows data root', () => {
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
files.read.mockReturnValue('[UserDataSet]\nUserDataSavePath=D:\\QQ\\Tencent Files\n');
expect(qqDataRoots()).toEqual(['D:\\QQ\\Tencent Files']);
expect(files.read).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,106 @@
import { describe, expect, it } from 'vitest';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
accountDirName,
loginDbPaths,
ntMsgDbPath,
tencentFilesRootFromUserDataInfo,
} from '../src/qq-account/paths';
const IS_WINDOWS = process.platform === 'win32';
describe('QQ data paths', () => {
it('derives the Linux account directory from the account uid', () => {
// md5(md5("u_test_account") + "nt_kernel")
expect(accountDirName('u_test_account')).toBe('nt_qq_7b9b3c3f2cf89e087b2e024777ea5f5f');
});
it('lists the login.db locations of the current platform', () => {
const root = 'root';
const windows = path.join(root, 'nt_qq', 'global', 'nt_db', 'login.db');
expect(loginDbPaths(root)).toEqual(
IS_WINDOWS ? [windows] : [path.join(root, 'global', 'nt_db', 'login.db'), windows],
);
});
it('locates an account database per platform', () => {
const root = 'root';
const account = { uin: '10001', uid: 'u_test_account' };
expect(ntMsgDbPath(root, account)).toBe(
IS_WINDOWS
? path.join(root, '10001', 'nt_qq', 'nt_db', 'nt_msg.db')
: path.join(root, 'nt_qq_7b9b3c3f2cf89e087b2e024777ea5f5f', 'nt_db', 'nt_msg.db'),
);
});
});
describe('Windows data root detection', () => {
/** Recreate `<Public>/Documents/Tencent/QQ/UserDataInfo.ini` for a fake home. */
async function withUserDataInfo(
contents: string,
run: (home: string) => Promise<void> | void,
): Promise<void> {
const base = await mkdtemp(path.join(os.tmpdir(), 'snowluma-ini-'));
try {
const home = path.join(base, 'user');
const ini = path.join(base, 'Public', 'Documents', 'Tencent', 'QQ', 'UserDataInfo.ini');
await mkdir(path.dirname(ini), { recursive: true });
await writeFile(ini, contents, 'utf8');
await run(home);
} finally {
await rm(base, { recursive: true, force: true });
}
}
it('reads UserDataSavePath from the ini QQ itself writes', async () => {
await withUserDataInfo(
'[UserDataSet]\nUserDataSavePath=D:\\Tencent\\Tencent Files\n',
(home) => {
expect(tencentFilesRootFromUserDataInfo(home)).toBe('D:\\Tencent\\Tencent Files');
},
);
});
it('appends the Tencent Files segment when the ini stops one level short', async () => {
await withUserDataInfo('[UserDataSet]\nUserDataSavePath=D:\\Tencent\n', (home) => {
expect(tencentFilesRootFromUserDataInfo(home)).toBe('D:\\Tencent\\Tencent Files');
});
});
it('ignores other sections and the UserDataSavePathType key', async () => {
await withUserDataInfo(
[
'[UserDataImportSet]',
'OldVerDataPath=D:\\Legacy\\Tencent Files',
'[UserDataSet]',
'UserDataSavePathType=2',
'UserDataSavePath=E:\\Current\\Tencent Files',
'',
].join('\n'),
(home) => {
expect(tencentFilesRootFromUserDataInfo(home)).toBe('E:\\Current\\Tencent Files');
},
);
});
it('reports nothing when the ini is missing', async () => {
const base = await mkdtemp(path.join(os.tmpdir(), 'snowluma-ini-'));
try {
expect(tencentFilesRootFromUserDataInfo(path.join(base, 'absent'))).toBeNull();
} finally {
await rm(base, { recursive: true, force: true });
}
});
it('reports nothing when the ini has no usable path', async () => {
await withUserDataInfo('[UserDataSet]\nUserDataSavePath=\n', (home) => {
expect(tencentFilesRootFromUserDataInfo(home)).toBeNull();
});
});
});
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { detectAccountLogins } from '../src/qq-account/detect';
/**
* Account detection against a real QQ data directory.
*
* Point `SNOWLUMA_QQ_DATA_ROOT` at one (for example `~/.config/QQ`) to run
* these; without it they are skipped, which is what CI and any machine without
* the client do. They are the only check that exercises login.db as QQ actually
* writes it, together with the holder probe on the databases it currently holds.
*/
const REAL_DATA_ROOT = process.env.SNOWLUMA_QQ_DATA_ROOT;
describe('account detection against real client data', () => {
it.runIf(Boolean(REAL_DATA_ROOT))('reads the accounts and their processes', async () => {
const accounts = await detectAccountLogins([REAL_DATA_ROOT!]);
expect(accounts.length).toBeGreaterThan(0);
for (const account of accounts) {
expect(account.uin).toMatch(/^\d{5,10}$/);
expect(account.uid).toMatch(/^u_/);
if (account.pid !== null) {
expect(Number.isInteger(account.pid)).toBe(true);
expect(() => process.kill(account.pid!, 0)).not.toThrow();
}
}
});
});
@@ -0,0 +1,324 @@
import { describe, expect, it } from 'vitest';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { SqliteDatabase } from '@snowluma/sqlite';
import {
DATABASE_HEADER_SIZE,
decryptDatabase,
detectAlgorithm,
type SqlcipherAlgorithm,
} from '../src/qq-account/sqlcipher';
import {
FIXTURE_HEADER_SIZE,
FIXTURE_PAGE_SIZE,
FIXTURE_PASSPHRASE,
FIXTURE_WAL_FRAME_HEADER_SIZE,
FIXTURE_WAL_HEADER_SIZE,
encryptImage,
encryptWal,
reserveBytes,
walChecksum,
type FixtureAlgorithm,
} from './qq-account-fixtures';
const SHA1_SHA512: FixtureAlgorithm = { pageHmac: 'sha1', kdfHmac: 'sha512' };
/**
* Reserved space SQLite is told about for an encrypted database: room for the
* IV plus the largest page digest. SQLCipher's own reserve always fits inside
* it, which is why the tail of every page is disposable.
*/
const HEADER_RESERVED_BYTES = 80;
/** Offset of one frame in a `-wal` buffer. */
function frameOffset(index: number): number {
return FIXTURE_WAL_HEADER_SIZE + index * (FIXTURE_WAL_FRAME_HEADER_SIZE + FIXTURE_PAGE_SIZE);
}
/** Last frame index a merge may have applied, or -1 when nothing was committed. */
function lastCommitFrame(wal: Buffer, maxFrameIndex = frameCount(wal) - 1): number {
let last = -1;
for (let index = 0; index <= maxFrameIndex; index++) {
if (wal.readUInt32BE(frameOffset(index) + 4) !== 0) {
last = index;
}
}
return last;
}
function frameCount(wal: Buffer): number {
const frameSize = FIXTURE_WAL_FRAME_HEADER_SIZE + FIXTURE_PAGE_SIZE;
return Math.floor((wal.length - FIXTURE_WAL_HEADER_SIZE) / frameSize);
}
/** Frames up to and including the last commit, i.e. what a merge should apply. */
function committedFrames(wal: Buffer, maxFrameIndex?: number): number {
return lastCommitFrame(wal, maxFrameIndex) + 1;
}
/** Database size the last applicable commit declares. */
function lastCommitSize(wal: Buffer, maxFrameIndex?: number): number {
return wal.readUInt32BE(frameOffset(lastCommitFrame(wal, maxFrameIndex)) + 4);
}
/** Page content as the last applicable commit left it, keyed by page number. */
function committedPages(wal: Buffer, maxFrameIndex?: number): Map<number, Buffer> {
const pages = new Map<number, Buffer>();
const last = lastCommitFrame(wal, maxFrameIndex);
const size = lastCommitSize(wal, maxFrameIndex);
for (let index = 0; index <= last; index++) {
const pageNumber = wal.readUInt32BE(frameOffset(index));
if (pageNumber >= 1 && pageNumber <= size) {
pages.set(
pageNumber,
wal.subarray(
frameOffset(index) + FIXTURE_WAL_FRAME_HEADER_SIZE,
frameOffset(index + 1),
),
);
}
}
return pages;
}
/** Compare one merged page against the page a frame carried. */
function expectPageMatches(
merged: Buffer,
expectedPage: Buffer,
pageNumber: number,
algorithm: FixtureAlgorithm,
): void {
const reserve = reserveBytes(algorithm);
const start = (pageNumber - 1) * FIXTURE_PAGE_SIZE;
// Page 1's SQLite header carries the fields the merge keeps consistent with
// the assembled image (change counter, page count, journal mode), so only the
// page body is compared there.
const bodyStart = pageNumber === 1 ? 100 : 0;
expect(merged.subarray(start + bodyStart, start + FIXTURE_PAGE_SIZE - reserve)).toEqual(
expectedPage.subarray(bodyStart, FIXTURE_PAGE_SIZE - reserve),
);
expect(
merged
.subarray(start + FIXTURE_PAGE_SIZE - reserve, start + FIXTURE_PAGE_SIZE)
.equals(Buffer.alloc(reserve)),
).toBe(true);
}
/** A plaintext image with the header fields the merge keeps consistent. */
function plaintextImage(pageCount: number, marker = 0): Buffer {
const image = Buffer.alloc(pageCount * FIXTURE_PAGE_SIZE);
for (let page = 0; page < pageCount; page++) {
for (let offset = 0; offset < FIXTURE_PAGE_SIZE - HEADER_RESERVED_BYTES; offset++) {
image[page * FIXTURE_PAGE_SIZE + offset] = (page * 31 + offset + marker) % 251;
}
}
image.write('SQLite format 3\0', 0, 'latin1');
image.writeUInt16BE(FIXTURE_PAGE_SIZE, 16);
image[18] = 1;
image[19] = 1;
image[20] = HEADER_RESERVED_BYTES;
image[21] = 64;
image[22] = 32;
image[23] = 32;
image.writeUInt32BE(7, 24);
image.writeUInt32BE(pageCount, 28);
image.writeUInt32BE(7, 92);
return image;
}
/** A real SQLite database in WAL mode, plus the sidecar SQLite wrote for it. */
async function realWalDatabase(
directory: string,
inserts: string[][],
): Promise<{ image: Buffer; wal: Buffer; db: SqliteDatabase }> {
const file = path.join(directory, 'source.db');
const db = new SqliteDatabase(file);
await db.exec('PRAGMA wal_autocheckpoint = 0');
await db.exec('CREATE TABLE t(value TEXT)');
for (const [value] of inserts) {
await db.prepare('INSERT INTO t VALUES (?)').run(value);
}
return { image: await readFile(file), wal: await readFile(`${file}-wal`), db };
}
describe('SQLCipher page decryption', () => {
it('round-trips a page image and normalizes the header', () => {
const plain = plaintextImage(3);
plain[18] = 2; // stored as a WAL-mode database, like QQ writes them
plain[19] = 2;
const encrypted = encryptImage(plain, SHA1_SHA512);
expect(detectAlgorithm(encrypted, FIXTURE_PASSPHRASE)).toEqual(SHA1_SHA512);
const decrypted = decryptDatabase(encrypted, null, FIXTURE_PASSPHRASE, SHA1_SHA512);
expect(decrypted).not.toBeNull();
const expected = Buffer.from(plain);
expected[18] = 1;
expected[19] = 1;
expected.writeUInt32BE(3, 28);
expected.writeUInt32BE(7, 92);
expect(decrypted!.image.equals(expected)).toBe(true);
expect(decrypted!.walFrames).toBe(0);
});
it('probes the algorithm pair instead of assuming one', () => {
const plain = plaintextImage(2);
for (const algorithm of [
{ pageHmac: 'sha256', kdfHmac: 'sha256' },
{ pageHmac: 'sha512', kdfHmac: 'sha1' },
{ pageHmac: 'none', kdfHmac: 'sha512' },
] satisfies SqlcipherAlgorithm[]) {
const encrypted = encryptImage(plain, algorithm);
expect(detectAlgorithm(encrypted, FIXTURE_PASSPHRASE)).toEqual(algorithm);
expect(
decryptDatabase(encrypted, null, FIXTURE_PASSPHRASE, algorithm)!.image.equals(
plain,
),
).toBe(true);
}
});
it('rejects a wrong passphrase and a file that is too short', () => {
const encrypted = encryptImage(plaintextImage(2), SHA1_SHA512);
expect(detectAlgorithm(encrypted, 'wrong-passphrase')).toBeNull();
expect(
detectAlgorithm(Buffer.alloc(DATABASE_HEADER_SIZE + 512), FIXTURE_PASSPHRASE),
).toBeNull();
expect(decryptDatabase(Buffer.alloc(64), null, FIXTURE_PASSPHRASE, SHA1_SHA512)).toBeNull();
});
});
describe('SQLCipher WAL folding', () => {
it('reproduces the frame checksums SQLite itself wrote', async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'snowluma-wal-'));
try {
const { wal, db } = await realWalDatabase(directory, [['a'], ['b']]);
const littleEndian = wal.readUInt32BE(0) === 0x377f0682;
const frameSize = FIXTURE_WAL_FRAME_HEADER_SIZE + FIXTURE_PAGE_SIZE;
expect((wal.length - FIXTURE_WAL_HEADER_SIZE) % frameSize).toBe(0);
const header = walChecksum(
[0, 0],
[wal.subarray(0, FIXTURE_WAL_HEADER_SIZE - 8)],
littleEndian,
);
expect(header).toEqual([wal.readUInt32BE(24), wal.readUInt32BE(28)]);
let running = header;
for (
let offset = FIXTURE_WAL_HEADER_SIZE;
offset + frameSize <= wal.length;
offset += frameSize
) {
const frame = wal.subarray(offset, offset + FIXTURE_WAL_FRAME_HEADER_SIZE);
running = walChecksum(
running,
[
frame.subarray(0, 8),
wal.subarray(offset + FIXTURE_WAL_FRAME_HEADER_SIZE, offset + frameSize),
],
littleEndian,
);
expect(running).toEqual([frame.readUInt32BE(16), frame.readUInt32BE(20)]);
}
await db.close();
} finally {
await rm(directory, { recursive: true, force: true });
}
});
it('folds every committed frame into the decrypted image', async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'snowluma-wal-'));
try {
const { image, wal, db } = await realWalDatabase(directory, [['a'], ['b']]);
const encryptedImage = encryptImage(image, SHA1_SHA512);
const encryptedWal = encryptWal(wal, encryptedImage, SHA1_SHA512);
const decrypted = decryptDatabase(
encryptedImage,
encryptedWal,
FIXTURE_PASSPHRASE,
SHA1_SHA512,
);
expect(decrypted!.walFrames).toBe(committedFrames(wal));
expect(decrypted!.image.length).toBe(lastCommitSize(wal) * FIXTURE_PAGE_SIZE);
// Every page a committed frame carried has to arrive as it was written,
// apart from the on-disk IV/HMAC region the encryptor owns.
for (const [pageNumber, page] of committedPages(wal)) {
expectPageMatches(decrypted!.image, page, pageNumber, SHA1_SHA512);
}
await db.close();
} finally {
await rm(directory, { recursive: true, force: true });
}
});
it('keeps the intact prefix of a WAL torn by a running client', async () => {
const directory = await mkdtemp(path.join(os.tmpdir(), 'snowluma-wal-'));
try {
const { image, wal, db } = await realWalDatabase(directory, [['a'], ['b']]);
const encryptedImage = encryptImage(image, SHA1_SHA512);
const encryptedWal = encryptWal(wal, encryptedImage, SHA1_SHA512);
const frameSize = FIXTURE_WAL_FRAME_HEADER_SIZE + FIXTURE_PAGE_SIZE;
const frameCount = (wal.length - FIXTURE_WAL_HEADER_SIZE) / frameSize;
// Shred the last frame's page: everything after the previous commit
// must be dropped rather than half-applied.
encryptedWal[encryptedWal.length - 8] ^= 0xff;
const decrypted = decryptDatabase(
encryptedImage,
encryptedWal,
FIXTURE_PASSPHRASE,
SHA1_SHA512,
);
expect(decrypted).not.toBeNull();
expect(decrypted!.walFrames).toBeLessThan(frameCount);
// Frames up to the tear survive; the merge still stops at the last
// commit they contain rather than applying a partial transaction.
expect(decrypted!.walFrames).toBe(committedFrames(wal, frameCount - 2));
// Everything the surviving commits wrote has to be in the image.
for (const [pageNumber, page] of committedPages(wal, frameCount - 2)) {
expectPageMatches(decrypted!.image, page, pageNumber, SHA1_SHA512);
}
await db.close();
} finally {
await rm(directory, { recursive: true, force: true });
}
});
});
describe('SQLCipher header handling', () => {
it('keeps the client header opaque and stores the salt at the start of page 1', () => {
const plain = plaintextImage(2);
const encrypted = encryptImage(plain, SHA1_SHA512);
const header = encrypted.subarray(0, FIXTURE_HEADER_SIZE);
expect(header.equals(Buffer.alloc(FIXTURE_HEADER_SIZE))).toBe(true);
expect(
encrypted
.subarray(FIXTURE_HEADER_SIZE + 16, FIXTURE_HEADER_SIZE + 48)
.equals(plain.subarray(16, 48)),
).toBe(false);
});
});
@@ -0,0 +1,86 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtemp, mkdir, rename, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
import { watchAccountFiles } from '../src/qq-account/watch';
// Keep native filesystem coverage aligned with qqDataRootsForPid's supported platforms.
// macOS is only a development host here: fs.watch() can return before FSEvents
// registration, losing immediate writes. Deterministic logic tests run on every OS.
describe.skipIf(process.platform !== 'win32' && process.platform !== 'linux')(
'account file changes on supported filesystems',
() => {
let root: string;
const subscriptions: Array<() => void> = [];
beforeEach(async () => {
root = await mkdtemp(path.join(os.tmpdir(), 'snowluma-account-watch-'));
});
afterEach(async () => {
for (const stop of subscriptions.splice(0)) {
stop();
}
await rm(root, { recursive: true, force: true });
});
function subscribe(dbPaths = [path.join(root, 'login.db')]) {
const changed = vi.fn();
subscriptions.push(watchAccountFiles(dbPaths, changed));
return changed;
}
it('observes database and WAL changes while ignoring message data and SHM', async () => {
const dbPath = path.join(root, 'login.db');
const changed = subscribe([dbPath, dbPath]);
await writeFile(dbPath, 'login');
await vi.waitFor(() => expect(changed).toHaveBeenCalled());
const afterDatabase = changed.mock.calls.length;
await writeFile(`${dbPath}-wal`, 'wal');
await vi.waitFor(() =>
expect(changed.mock.calls.length).toBeGreaterThan(afterDatabase),
);
const afterWal = changed.mock.calls.length;
await writeFile(path.join(root, 'nt_msg.db'), 'message');
await writeFile(`${dbPath}-shm`, 'shared memory');
await delay(350);
expect(changed).toHaveBeenCalledTimes(afterWal);
});
it('observes database replacement and WAL recreation', async () => {
const dbPath = path.join(root, 'login.db');
await writeFile(dbPath, 'initial');
const changed = subscribe();
await writeFile(path.join(root, 'replacement'), 'replacement');
await rename(path.join(root, 'replacement'), dbPath);
await vi.waitFor(() => expect(changed).toHaveBeenCalled());
const afterReplacement = changed.mock.calls.length;
await writeFile(dbPath, 'updated');
await vi.waitFor(() =>
expect(changed.mock.calls.length).toBeGreaterThan(afterReplacement),
);
const afterUpdate = changed.mock.calls.length;
await writeFile(`${dbPath}-wal`, 'wal');
await vi.waitFor(() => expect(changed.mock.calls.length).toBeGreaterThan(afterUpdate));
const afterWal = changed.mock.calls.length;
await rm(`${dbPath}-wal`);
await writeFile(`${dbPath}-wal`, 'new wal');
await vi.waitFor(() => expect(changed.mock.calls.length).toBeGreaterThan(afterWal));
});
it('starts watching login data created after QQ process discovery', async () => {
const dbPath = path.join(root, 'QQ', 'global', 'nt_db', 'login.db');
const changed = subscribe([dbPath]);
await mkdir(path.dirname(dbPath), { recursive: true });
await writeFile(dbPath, 'initial');
await vi.waitFor(() => expect(changed).toHaveBeenCalled());
const afterDatabase = changed.mock.calls.length;
await writeFile(`${dbPath}-wal`, 'login');
await vi.waitFor(() =>
expect(changed.mock.calls.length).toBeGreaterThan(afterDatabase),
);
});
},
);
@@ -0,0 +1,253 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { EventEmitter } from 'node:events';
import type { FSWatcher, WatchListener } from 'node:fs';
import path from 'node:path';
import { createLogger } from '@snowluma/common/logger';
import { watchAccountChangesForPids, watchAccountFiles } from '../src/qq-account/watch';
const files = vi.hoisted(() => ({
watch: vi.fn<
(
directory: string,
options: { persistent: boolean },
listener: WatchListener<string>,
) => FSWatcher
>(),
}));
vi.mock('node:fs', async (importOriginal) => ({
...(await importOriginal<typeof import('node:fs')>()),
watch: files.watch,
}));
class TestWatcher extends EventEmitter implements FSWatcher {
// Keep callbacks reachable after close to exercise queued, stale events.
close = vi.fn();
ref() {
return this;
}
unref() {
return this;
}
}
const root = path.resolve('account-watch-fixture');
const directories = new Set<string>();
const watchers: TestWatcher[] = [];
const subscriptions: Array<() => void> = [];
const platform = process.platform;
beforeEach(() => {
vi.useFakeTimers();
directories.add(root);
files.watch.mockImplementation((directory, _options, listener) => {
if (!directories.has(directory)) {
throw Object.assign(new Error('directory missing'), { code: 'ENOENT' });
}
const watcher = new TestWatcher();
watcher.on('change', listener);
watchers.push(watcher);
return watcher;
});
});
afterEach(() => {
for (const stop of subscriptions.splice(0)) {
stop();
}
Object.defineProperty(process, 'platform', { value: platform, configurable: true });
watchers.length = 0;
directories.clear();
files.watch.mockReset();
vi.useRealTimers();
vi.restoreAllMocks();
});
function subscribe(dbPaths = [path.join(root, 'login.db')]) {
const changed = vi.fn();
const log = createLogger('AccountWatchTest');
const warning = vi.spyOn(log, 'warn');
const stop = watchAccountFiles(dbPaths, changed, log);
subscriptions.push(stop);
return { changed, stop, warning };
}
// Native filesystem delivery is covered separately on the production platforms.
// These tests control event ordering and the debounce clock, not OS startup timing.
describe('account file changes', () => {
it('coalesces login database and WAL changes while ignoring message data and SHM', () => {
const dbPath = path.join(root, 'login.db');
const { changed } = subscribe([dbPath, dbPath]);
expect(files.watch).toHaveBeenCalledExactlyOnceWith(
root,
{ persistent: false },
expect.any(Function),
);
const watcher = watchers[0]!;
watcher.emit('change', 'change', 'login.db');
vi.advanceTimersByTime(100);
watcher.emit('change', 'change', 'login.db-wal');
vi.advanceTimersByTime(249);
expect(changed).not.toHaveBeenCalled();
watcher.emit('change', 'change', 'nt_msg.db');
watcher.emit('change', 'change', 'login.db-shm');
vi.advanceTimersByTime(1);
expect(changed).toHaveBeenCalledOnce();
vi.advanceTimersByTime(1000);
expect(changed).toHaveBeenCalledOnce();
});
it('keeps the directory watch across database replacement and WAL recreation', () => {
const { changed } = subscribe();
const watcher = watchers[0]!;
watcher.emit('change', 'rename', 'replacement');
vi.advanceTimersByTime(250);
expect(changed).not.toHaveBeenCalled();
watcher.emit('change', 'rename', 'login.db');
vi.advanceTimersByTime(250);
watcher.emit('change', 'change', 'login.db');
vi.advanceTimersByTime(250);
watcher.emit('change', 'rename', 'login.db-wal');
vi.advanceTimersByTime(250);
watcher.emit('change', 'rename', 'login.db-wal');
watcher.emit('change', 'rename', 'login.db-wal');
vi.advanceTimersByTime(250);
expect(changed).toHaveBeenCalledTimes(4);
expect(files.watch).toHaveBeenCalledOnce();
expect(watcher.close).not.toHaveBeenCalled();
});
it('moves to newly created directories one level at a time', () => {
const dbDirectory = path.join(root, 'QQ', 'global', 'nt_db');
const { changed } = subscribe([path.join(dbDirectory, 'login.db')]);
const ancestor = watchers[0]!;
const attempts = files.watch.mock.calls.length;
ancestor.emit('change', 'rename', 'unrelated');
ancestor.emit('change', 'change', 'login.db-wal');
expect(files.watch).toHaveBeenCalledTimes(attempts);
expect(vi.getTimerCount()).toBe(0);
let directory = root;
for (const segment of ['QQ', 'global', 'nt_db']) {
directory = path.join(directory, segment);
directories.add(directory);
const previous = watchers.at(-1)!;
previous.emit('change', 'rename', segment);
expect(previous.close).toHaveBeenCalledOnce();
expect(files.watch).toHaveBeenLastCalledWith(
directory,
{ persistent: false },
expect.any(Function),
);
}
vi.advanceTimersByTime(250);
expect(changed).toHaveBeenCalledOnce();
watchers.at(-1)!.emit('change', 'change', 'login.db-wal');
vi.advanceTimersByTime(250);
expect(changed).toHaveBeenCalledTimes(2);
});
it.each(['QQ', null])(
'observes an entire new directory tree from a %s ancestor event',
(filename) => {
const dbDirectory = path.join(root, 'QQ', 'global', 'nt_db');
const { changed, warning } = subscribe([path.join(dbDirectory, 'login.db')]);
const ancestor = watchers[0]!;
directories.add(dbDirectory);
ancestor.emit('change', 'rename', filename);
expect(ancestor.close).toHaveBeenCalledOnce();
expect(files.watch).toHaveBeenLastCalledWith(
dbDirectory,
{ persistent: false },
expect.any(Function),
);
const attempts = files.watch.mock.calls.length;
vi.advanceTimersByTime(250);
expect(changed).toHaveBeenCalledOnce();
ancestor.emit('change', 'rename', 'QQ');
ancestor.emit('error', new Error('late error'));
vi.advanceTimersByTime(250);
expect(files.watch).toHaveBeenCalledTimes(attempts);
expect(changed).toHaveBeenCalledOnce();
expect(warning).not.toHaveBeenCalled();
watchers.at(-1)!.emit('change', 'change', 'login.db-wal');
vi.advanceTimersByTime(250);
expect(changed).toHaveBeenCalledTimes(2);
},
);
it('notifies when the filesystem does not supply a filename', () => {
const { changed } = subscribe();
watchers[0]!.emit('change', 'change', null);
vi.advanceTimersByTime(250);
expect(changed).toHaveBeenCalledOnce();
});
it('closes every watcher and cancels pending notifications on disposal', () => {
const { changed, stop, warning } = subscribe([
path.join(root, 'login.db'),
path.join(root, 'other', 'login.db'),
]);
expect(watchers).toHaveLength(2);
watchers[0]!.emit('change', 'change', 'login.db');
vi.advanceTimersByTime(30);
expect(vi.getTimerCount()).toBe(1);
stop();
expect(vi.getTimerCount()).toBe(0);
for (const watcher of watchers) {
expect(watcher.close).toHaveBeenCalledOnce();
watcher.emit('change', 'rename', null);
watcher.emit('error', new Error('late error'));
}
vi.advanceTimersByTime(1000);
expect(changed).not.toHaveBeenCalled();
expect(warning).not.toHaveBeenCalled();
});
it('reports watch failures without starting a polling fallback', () => {
files.watch.mockImplementationOnce(() => {
throw Object.assign(new Error('access denied'), { code: 'EACCES' });
});
const { changed, warning } = subscribe();
expect(warning).toHaveBeenCalledWith(
'account watch failed: path=%s err=%s',
path.join(root, 'login.db'),
'access denied',
);
expect(vi.getTimerCount()).toBe(0);
vi.advanceTimersByTime(1000);
expect(files.watch).toHaveBeenCalledOnce();
expect(changed).not.toHaveBeenCalled();
});
it('reports asynchronous watch errors and closes the failed watcher', () => {
const { changed, warning } = subscribe();
const watcher = watchers[0]!;
watcher.emit('error', new Error('watch unavailable'));
expect(watcher.close).toHaveBeenCalledOnce();
expect(warning).toHaveBeenCalledWith(
'account watch failed: path=%s err=%s',
path.join(root, 'login.db'),
'watch unavailable',
);
watcher.emit('change', 'change', 'login.db');
vi.advanceTimersByTime(250);
expect(changed).not.toHaveBeenCalled();
});
it('does not start account file watchers through the production entry point on macOS', () => {
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
const changed = vi.fn();
subscriptions.push(watchAccountChangesForPids([123], changed));
expect(files.watch).not.toHaveBeenCalled();
expect(vi.getTimerCount()).toBe(0);
expect(changed).not.toHaveBeenCalled();
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,152 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { hasLowProcessIntegrity, type NativeRuntimeIntegrity } from '../src/runtime-integrity';
const native = vi.hoisted(() => ({ load: vi.fn(), error: vi.fn() }));
vi.mock('../src/injector', () => ({
getNativeHookAddon: native.load,
getNativeHookLoadError: native.error,
}));
const platform = process.platform;
const sample = (): NativeRuntimeIntegrity => ({
pid: process.pid,
executablePath: process.execPath,
process: { status: 'ok', rid: 8192 },
file: { status: 'ok', label: null },
});
beforeEach(() => {
vi.resetModules();
vi.resetAllMocks();
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
});
afterEach(() => {
Object.defineProperty(process, 'platform', { value: platform, configurable: true });
});
describe('runtime integrity diagnostics', () => {
it('warns only for a successfully read process token below Medium', () => {
const available = { ...sample(), status: 'available' as const };
for (const rid of [0, 4096]) {
expect(hasLowProcessIntegrity({ ...available, process: { status: 'ok', rid } })).toBe(
true,
);
}
for (const rid of [8192, 8448, 12288, 16384]) {
expect(
hasLowProcessIntegrity({
...available,
process: { status: 'ok', rid },
file: { status: 'ok', label: { rid: 4096, inherited: true, policy: 1 } },
}),
).toBe(false);
}
expect(hasLowProcessIntegrity({ status: 'unsupported' })).toBe(false);
expect(hasLowProcessIntegrity({ status: 'unavailable', error: 'unavailable' })).toBe(false);
expect(
hasLowProcessIntegrity({
...available,
process: { status: 'error', operation: 'OpenProcessToken', code: 5 },
}),
).toBe(false);
});
it('keeps one startup snapshot for logging and all WebUI polls', async () => {
const read = vi.fn().mockResolvedValue(sample());
native.load.mockReturnValue({ getRuntimeIntegrityAsync: read });
const { getRuntimeIntegrity } = await import('../src/runtime-integrity');
const pending = getRuntimeIntegrity();
expect(getRuntimeIntegrity()).toBe(pending);
const first = await pending;
expect(first).toMatchObject({
status: 'available',
process: { rid: 8192 },
file: { label: null },
});
read.mockResolvedValue({
...sample(),
file: { status: 'ok', label: { rid: 4096, inherited: true, policy: 1 } },
});
expect(await getRuntimeIntegrity()).toBe(first);
expect(read).toHaveBeenCalledTimes(1);
});
it.each(['process', 'file'])(
'retains %s query errors and the other successful result',
async (field) => {
native.load.mockReturnValue({
getRuntimeIntegrityAsync: async () => ({
...sample(),
[field]: { status: 'error', operation: 'Win32Query', code: 5 },
}),
});
const { getRuntimeIntegrity } = await import('../src/runtime-integrity');
expect(await getRuntimeIntegrity()).toMatchObject({
status: 'available',
[field]: { status: 'error', operation: 'Win32Query', code: 5 },
});
},
);
it('reports old addons as unavailable rather than Medium', async () => {
native.load.mockReturnValue({});
const { getRuntimeIntegrity } = await import('../src/runtime-integrity');
expect(await getRuntimeIntegrity()).toMatchObject({
status: 'unavailable',
error: expect.stringContaining('不支持'),
});
});
it('retains the loader error for the startup log', async () => {
native.load.mockReturnValue(null);
native.error.mockReturnValue('dlopen failed: incompatible binary');
const { getRuntimeIntegrity } = await import('../src/runtime-integrity');
expect(await getRuntimeIntegrity()).toMatchObject({
status: 'unavailable',
error: 'dlopen failed: incompatible binary',
});
});
it.each([
null,
{ ...sample(), pid: process.pid + 1 },
{ ...sample(), process: { status: 'ok', rid: -1 } },
{ ...sample(), file: { status: 'ok', label: { rid: 4096, inherited: 'yes', policy: 1 } } },
])('rejects malformed or wrong-process native results', async (result) => {
native.load.mockReturnValue({ getRuntimeIntegrityAsync: async () => result });
const { getRuntimeIntegrity } = await import('../src/runtime-integrity');
expect(await getRuntimeIntegrity()).toMatchObject({
status: 'unavailable',
error: expect.stringContaining('无效数据'),
});
});
it('preserves rejected errors without repeatedly probing on WebUI polls', async () => {
const read = vi
.fn()
.mockRejectedValueOnce(new Error('allocation failed'))
.mockResolvedValue(sample());
native.load.mockReturnValue({ getRuntimeIntegrityAsync: read });
const { getRuntimeIntegrity } = await import('../src/runtime-integrity');
expect(await getRuntimeIntegrity()).toMatchObject({
status: 'unavailable',
error: 'allocation failed',
});
expect(await getRuntimeIntegrity()).toMatchObject({
status: 'unavailable',
error: 'allocation failed',
});
expect(read).toHaveBeenCalledTimes(1);
});
it('does not load the addon on non-Windows hosts', async () => {
Object.defineProperty(process, 'platform', { value: 'linux', configurable: true });
const { getRuntimeIntegrity } = await import('../src/runtime-integrity');
expect(await getRuntimeIntegrity()).toEqual({ status: 'unsupported' });
expect(native.load).not.toHaveBeenCalled();
});
});
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["node"],
"noEmit": true
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules"
]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
environment: 'node',
},
});
+40
View File
@@ -0,0 +1,40 @@
// pnpm --filter @snowluma/common exec vitest bench --run bench/logger.bench.ts
import { bench, describe, vi } from 'vitest';
vi.stubEnv('SNOWLUMA_LOG_LEVEL', 'trace');
vi.stubEnv('SNOWLUMA_LOG_FILE', '0');
vi.stubEnv('SNOWLUMA_LOG_FILE_LEVEL', 'debug');
vi.stubEnv('SNOWLUMA_TRACE_BUFFER', '5000');
const { createLogger, getLogSnapshot, getRecentLogs, setLogLevel } = await import('../src/logger');
const stdout = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
const log = createLogger('Benchmark');
for (let i = 0; i < 5000; i += 1) {
log.trace('trace %d', i);
if (i % 5 === 0) {
log.info('normal %d', i);
}
}
stdout.mockRestore();
describe('6,000 retained records', () => {
bench('recent 30', () => {
getRecentLogs(30);
});
bench('recent 300', () => {
getRecentLogs(300);
});
bench('complete export', () => {
getLogSnapshot();
});
});
setLogLevel('info');
describe('file-only debug records with disk disabled', () => {
bench('format and route debug', () => {
log.debug('packet command=%s bytes=%d', 'Example.Service', 1024);
});
});
+123
View File
@@ -0,0 +1,123 @@
{
"license": "SEE LICENSE IN LICENSE",
"name": "@snowluma/common",
"version": "2.4.2",
"private": true,
"type": "module",
"description": "SnowLuma shared infrastructure — leaf-package utilities with zero business dependencies. Logger / hex codec / log file transport / log line summariser / packet sender wire-protocol types / runtime config loader. Consumed by @snowluma/protocol, @snowluma/core, and @snowluma/onebot. Per-file subpath exports keep import sites unambiguous and let the bundler tree-shake aggressively (importing `@snowluma/common/hex` doesn't pull in `node:fs` from the logger transport).",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
"./webui-target": {
"types": "./src/webui-target.ts",
"default": "./src/webui-target.ts"
},
"./update-source": {
"types": "./src/update-source.ts",
"default": "./src/update-source.ts"
},
"./semver": {
"types": "./src/semver.ts",
"default": "./src/semver.ts"
},
"./version": {
"types": "./src/version.ts",
"default": "./src/version.ts"
},
"./native-path": {
"types": "./src/native-path.ts",
"default": "./src/native-path.ts"
},
"./errors": {
"types": "./src/errors.ts",
"default": "./src/errors.ts"
},
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./logger": {
"types": "./src/logger.ts",
"default": "./src/logger.ts"
},
"./coerce": {
"types": "./src/coerce.ts",
"default": "./src/coerce.ts"
},
"./env": {
"types": "./src/env.ts",
"default": "./src/env.ts"
},
"./notification-templates": {
"types": "./src/notification-templates.ts",
"default": "./src/notification-templates.ts"
},
"./single-flight-cache": {
"types": "./src/single-flight-cache.ts",
"default": "./src/single-flight-cache.ts"
},
"./concurrency": {
"types": "./src/concurrency.ts",
"default": "./src/concurrency.ts"
},
"./hex": {
"types": "./src/hex.ts",
"default": "./src/hex.ts"
},
"./json": {
"types": "./src/json.ts",
"default": "./src/json.ts"
},
"./log-file-transport": {
"types": "./src/log-file-transport.ts",
"default": "./src/log-file-transport.ts"
},
"./log-sanitize": {
"types": "./src/log-sanitize.ts",
"default": "./src/log-sanitize.ts"
},
"./log-summary": {
"types": "./src/log-summary.ts",
"default": "./src/log-summary.ts"
},
"./packet-sender": {
"types": "./src/packet-sender.ts",
"default": "./src/packet-sender.ts"
},
"./protocol-types": {
"types": "./src/protocol-types.ts",
"default": "./src/protocol-types.ts"
},
"./runtime": {
"types": "./src/runtime.ts",
"default": "./src/runtime.ts"
},
"./uin": {
"types": "./src/uin.ts",
"default": "./src/uin.ts"
},
"./access-token": {
"types": "./src/access-token.ts",
"default": "./src/access-token.ts"
}
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"devDependencies": {
"@types/node": "catalog:",
"@types/semver": "7.7.1",
"typescript": "catalog:",
"vitest": "4.1.12"
},
"dependencies": {
"@zxcvbn-ts/core": "^4.1.2",
"@zxcvbn-ts/language-common": "^4.1.3",
"@zxcvbn-ts/language-en": "^4.1.1",
"semver": "7.7.4"
},
"engines": {
"node": "^24.22.1 || >=26.12.1"
}
}
+68
View File
@@ -0,0 +1,68 @@
import { ZxcvbnFactory } from '@zxcvbn-ts/core';
import { adjacencyGraphs, dictionary } from '@zxcvbn-ts/language-common';
import { translations } from '@zxcvbn-ts/language-en';
export const ACCESS_TOKEN_MIN_LENGTH = 16;
export const ACCESS_TOKEN_MIN_SCORE = 3;
export type AccessTokenAssessmentReason = 'empty' | 'too-short' | 'guessable' | 'acceptable';
export interface AccessTokenAssessment {
acceptable: boolean;
reason: AccessTokenAssessmentReason;
score: number;
}
const checker = new ZxcvbnFactory({
translations,
graphs: adjacencyGraphs,
dictionary,
});
function normalizeUserInputs(values: readonly (string | number)[]): Array<string | number> {
const unique = new Set<string | number>(['SnowLuma', 'OneBot']);
for (const value of values) {
if (typeof value === 'number') {
if (Number.isFinite(value)) {
unique.add(value);
}
continue;
}
const trimmed = value.trim();
if (trimmed) {
unique.add(trimmed.slice(0, 256));
}
}
return [...unique].slice(0, 24);
}
/**
* Evaluate a newly chosen OneBot access token against the shared product
* policy. Callers decide whether an empty token is permitted in their own
* trust context; an empty value is never considered strong here.
*/
export function assessAccessToken(
token: string,
userInputs: readonly (string | number)[] = [],
): AccessTokenAssessment {
if (!token) {
return { acceptable: false, reason: 'empty', score: 0 };
}
if (token.length < ACCESS_TOKEN_MIN_LENGTH) {
return { acceptable: false, reason: 'too-short', score: 0 };
}
const score = checker.check(token, normalizeUserInputs(userInputs)).score;
if (score < ACCESS_TOKEN_MIN_SCORE) {
return { acceptable: false, reason: 'guessable', score };
}
return { acceptable: true, reason: 'acceptable', score };
}
+34
View File
@@ -0,0 +1,34 @@
// Scalar coercion primitives for normalizing untrusted JSON config into
// typed values with fallbacks. Zero dependencies — these were duplicated
// verbatim across notifications/config and webui/ui-config; this is their
// single home. Input tolerance is deliberate (numeric strings coerce), but
// validity is strict (a wrong type yields the caller's fallback, never a
// silent 0/'' — except where the primitive's contract says otherwise).
/** True only for a plain, non-null, non-array object. */
export function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/** The value when it is a real boolean, else the fallback. Strings/numbers
* are NOT coerced — `'true'` and `1` both yield the fallback. */
export function boolOr(value: unknown, fallback: boolean): boolean {
return typeof value === 'boolean' ? value : fallback;
}
/** A finite number (numeric strings coerced) clamped to `[min, max]`, else
* the fallback. Fractional precision is preserved — use `clampInt` to floor. */
export function clampNum(value: unknown, min: number, max: number, fallback: number): number {
const n = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : NaN;
if (!Number.isFinite(n)) {
return fallback;
}
return Math.min(max, Math.max(min, n));
}
/** `clampNum` truncated toward zero — clamp first, then drop the fraction. */
export function clampInt(value: unknown, min: number, max: number, fallback: number): number {
return Math.trunc(clampNum(value, min, max, fallback));
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Map `items` through `fn` in sequential batches of at most `concurrency`
* items, awaiting each batch (`Promise.all`) before starting the next.
* Results come back in input order.
*
* Batched, not a rolling pool: every item in a batch must settle before the
* next batch starts. This caps peak parallelism — used to keep rate-limited
* fan-out (e.g. the per-uin OIDB profile fetches during forward-node
* enrichment) from bursting past a safe width and tripping Tencent
* risk-control. Pair it with a cap on `items` (slice) and per-item error
* handling inside `fn` for a fully bounded, best-effort enrichment — the
* shape that was previously hand-rolled at each such call site.
*/
export async function mapWithConcurrency<T, R>(
items: readonly T[],
concurrency: number,
fn: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
const limit = Math.max(1, Math.trunc(concurrency));
const results = new Array<R>(items.length);
for (let i = 0; i < items.length; i += limit) {
const batch = items.slice(i, i + limit);
const settled = await Promise.all(batch.map((item, j) => fn(item, i + j)));
for (let j = 0; j < settled.length; j++) {
results[i + j] = settled[j]!;
}
}
return results;
}
+34
View File
@@ -0,0 +1,34 @@
import { createLogger } from './logger';
const log = createLogger('Env');
/** Node's setTimeout delay is a signed 32-bit integer. */
const MAX_POSITIVE_INT = 2_147_483_647;
/** Read a positive integer environment variable.
*
* A blank value is absent and returns `fallback`. A present but unusable
* value is ignored, with a warning, and also returns `fallback`.
*/
export function positiveIntEnv(
name: string,
fallback: number,
options?: { max?: number; env?: NodeJS.ProcessEnv },
): number {
const max = options?.max ?? MAX_POSITIVE_INT;
const raw = (options?.env ?? process.env)[name];
if (typeof raw !== 'string' || !raw.trim()) {
return fallback;
}
const value = raw.trim();
const parsed = /^[1-9]\d*$/.test(value) ? Number(value) : Number.NaN;
if (!Number.isSafeInteger(parsed) || parsed > max) {
log.warn('%s=%j is ignored; expected a positive integer up to %d', name, raw, max);
return fallback;
}
return parsed;
}
+3
View File
@@ -0,0 +1,3 @@
export function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+27
View File
@@ -0,0 +1,27 @@
export function toHex(data: Uint8Array | Buffer): string {
return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString('hex');
}
export function toHexUpper(data: Uint8Array | Buffer): string {
return toHex(data).toUpperCase();
}
export function hexPreview(data: Uint8Array | Buffer, maxBytes = 64): string {
const limit = Math.max(0, Math.min(maxBytes, data.length));
const head = toHex(data.subarray(0, limit));
return data.length > limit ? `${head}...` : head;
}
export function fromHex(hex: string): Buffer {
if (hex.length % 2 !== 0) {
throw new Error('Invalid hex string length');
}
const buf = Buffer.alloc(hex.length / 2);
for (let i = 0; i < buf.length; i++) {
buf[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
}
return buf;
}
+6
View File
@@ -0,0 +1,6 @@
export type JsonPrimitive = string | number | boolean | null;
export type JsonValue = JsonPrimitive | JsonObject | JsonArray;
export interface JsonObject {
[key: string]: JsonValue;
}
export type JsonArray = JsonValue[];
File diff suppressed because it is too large. Load diff
+130
View File
@@ -0,0 +1,130 @@
const ESC = 0x1b;
const BEL = 0x07;
const C1_CSI = 0x9b;
const C1_ST = 0x9c;
const C1_OSC = 0x9d;
const C1_STRING_STARTERS = new Set([0x90, 0x98, 0x9e, 0x9f]);
function skipCsi(line: string, start: number): number {
for (let i = start; i < line.length; i += 1) {
const code = line.charCodeAt(i);
if (code >= 0x40 && code <= 0x7e) {
return i + 1;
}
}
return line.length;
}
function skipControlString(line: string, start: number, allowBell: boolean): number {
for (let i = start; i < line.length; i += 1) {
const code = line.charCodeAt(i);
if (allowBell && code === BEL) {
return i + 1;
}
if (code === C1_ST) {
return i + 1;
}
if (code === ESC && line.charCodeAt(i + 1) === 0x5c) {
return i + 2;
}
}
return line.length;
}
function skipEscSequence(line: string, start: number): number {
if (start >= line.length) {
return line.length;
}
const first = line.charCodeAt(start);
if (first === 0x5b) {
return skipCsi(line, start + 1);
}
if (first === 0x5d) {
return skipControlString(line, start + 1, true);
}
if (first === 0x50 || first === 0x58 || first === 0x5e || first === 0x5f) {
return skipControlString(line, start + 1, false);
}
let i = start;
while (i < line.length) {
const code = line.charCodeAt(i);
if (code < 0x20 || code > 0x2f) {
break;
}
i += 1;
}
const final = line.charCodeAt(i);
return final >= 0x30 && final <= 0x7e ? i + 1 : start;
}
/**
* Remove terminal presentation and control sequences from a log line.
*
* The parser handles both seven-bit ESC forms and eight-bit C1 forms,
* including CSI styling, OSC hyperlinks, and DCS/SOS/PM/APC control strings.
* TAB and LF remain available for formatted records such as stack traces.
* Unterminated terminal strings consume the remainder instead of leaking their
* invisible payload into persisted or downloaded logs.
*/
export function sanitizeLogLine(line: string): string {
const parts: string[] = [];
let start = 0;
for (let i = 0; i < line.length;) {
const code = line.charCodeAt(i);
let next: number;
if (code === ESC) {
next = skipEscSequence(line, i + 1);
} else if (code === C1_CSI) {
next = skipCsi(line, i + 1);
} else if (code === C1_OSC || C1_STRING_STARTERS.has(code)) {
next = skipControlString(line, i + 1, code === C1_OSC);
} else if (
(code < 0x20 && code !== 0x09 && code !== 0x0a) ||
(code >= 0x7f && code <= 0x9f)
) {
next = i + 1;
} else {
i += 1;
continue;
}
parts.push(line.slice(start, i));
start = next;
i = next;
}
if (start === 0) {
return line;
}
// Join contiguous spans once. Appending each character creates a retained
// V8 cons-string node per character in the in-memory log rings.
parts.push(line.slice(start));
const plain = parts.join('');
// A single visible span can remain a slice of the original input after join.
// Copy when that would retain more discarded text than visible text. Splitting
// into UTF-16 code units preserves unpaired surrogates and works in browsers.
if (plain.length > 0 && plain.length < line.length / 2) {
return plain.split('').join('');
}
return plain;
}
+383
View File
@@ -0,0 +1,383 @@
const MAX_FIELD = 40;
const MAX_TOTAL = 200;
const SENSITIVE_SEGMENTS = new Set([
'authorization',
'cookie',
'credential',
'credentials',
'password',
'passwd',
'secret',
'token',
'apikey',
'privatekey',
'sessionkey',
]);
function isSensitiveKey(key: string): boolean {
const segments = key
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.split(/[-_.\s]+/)
.filter(Boolean)
.map((segment) => segment.toLowerCase());
return segments.some((segment, index) => {
if (SENSITIVE_SEGMENTS.has(segment)) {
return true;
}
const next = segments[index + 1];
return (
next === 'key' && (segment === 'api' || segment === 'private' || segment === 'session')
);
});
}
function valueRepr(v: unknown, key?: string): string {
if (key !== undefined && isSensitiveKey(key)) {
return '"***"';
}
if (v === null) {
return 'null';
}
if (v === undefined) {
return 'undefined';
}
switch (typeof v) {
case 'string':
return v.length > MAX_FIELD ? `"${v.slice(0, MAX_FIELD)}..."` : `"${v}"`;
case 'number':
case 'boolean':
case 'bigint':
return String(v);
case 'object':
if (Array.isArray(v)) {
return `[len=${v.length}]`;
}
return '{...}';
default:
return typeof v;
}
}
/**
* Render a params object as a single line for logging. Skips deep
* traversal: nested objects collapse to `{...}`, arrays to `[len=N]`.
* Strings are quoted; long ones are truncated with an ellipsis.
*
* Output is capped at MAX_TOTAL chars; on overflow the tail is
* replaced with `...` so the next field doesn't get half-rendered.
*/
export function summarizeParams(params: unknown): string {
if (params === null || params === undefined) {
return '{}';
}
if (typeof params !== 'object') {
const s = String(params);
return s.length > MAX_TOTAL ? `${s.slice(0, MAX_TOTAL - 3)}...` : s;
}
if (Array.isArray(params)) {
return `[len=${params.length}]`;
}
const out: string[] = [];
let total = 0;
for (const [k, v] of Object.entries(params as Record<string, unknown>)) {
const entry = `${k}=${valueRepr(v, k)}`;
const separatorLength = out.length > 0 ? 1 : 0;
if (total + separatorLength + entry.length > MAX_TOTAL) {
if (out.length === 0) {
return `${entry.slice(0, MAX_TOTAL - 3)}...`;
}
const rendered = out.join(' ');
return rendered.length + 4 <= MAX_TOTAL
? `${rendered} ...`
: `${rendered.slice(0, MAX_TOTAL - 3)}...`;
}
out.push(entry);
total += separatorLength + entry.length;
}
return out.join(' ');
}
const ASSIGNMENT_START =
/(^|[^A-Za-z0-9_-])(["']?)([-_]*(?=[A-Za-z0-9_.-]*[A-Za-z])[A-Za-z0-9][A-Za-z0-9_.-]*)\2(\s*[:=]\s*)/gi;
const AUTHORIZATION_BOUNDARY_SEGMENTS = new Set([
'authorization',
'cookie',
'password',
'passwd',
'secret',
'token',
'apikey',
'privatekey',
'sessionkey',
]);
function keySegments(key: string): string[] {
return key
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.split(/[-_.\s]+/)
.filter(Boolean)
.map((segment) => segment.toLowerCase());
}
function isKeyKind(key: string, kind: 'authorization' | 'cookie'): boolean {
return keySegments(key).includes(kind);
}
function isAuthorizationBoundaryKey(key: string): boolean {
const segments = keySegments(key);
return segments.some((segment, index) => {
if (AUTHORIZATION_BOUNDARY_SEGMENTS.has(segment)) {
return true;
}
const next = segments[index + 1];
return (
next === 'key' && (segment === 'api' || segment === 'private' || segment === 'session')
);
});
}
function quotedValueEnd(message: string, start: number): number | undefined {
const quote = message[start];
if (quote !== '"' && quote !== "'") {
return undefined;
}
for (let index = start + 1; index < message.length; index += 1) {
if (message[index] === '\\') {
index += 1;
} else if (message[index] === quote) {
return index + 1;
}
}
return message.length;
}
function cookieValueEnd(message: string, start: number): number {
const initialQuotedEnd = quotedValueEnd(message, start);
let end: number;
if (initialQuotedEnd !== undefined) {
end = initialQuotedEnd;
} else {
end = start;
while (end < message.length && !/[;\s,}\]&\r\n]/.test(message[end]!)) {
end += 1;
}
}
while (end < message.length) {
let semicolon: number = end;
while (message[semicolon] === ' ' || message[semicolon] === '\t') {
semicolon += 1;
}
if (message[semicolon] !== ';') {
break;
}
let nameStart = semicolon + 1;
while (message[nameStart] === ' ' || message[nameStart] === '\t') {
nameStart += 1;
}
let nameEnd = nameStart;
while (/[A-Za-z0-9_-]/.test(message[nameEnd] ?? '')) {
nameEnd += 1;
}
if (nameEnd === nameStart) {
break;
}
end = nameEnd;
if (message[end] !== '=') {
continue;
}
const attribute = message.slice(nameStart, nameEnd).toLowerCase();
const valueStart = end + 1;
const quotedEnd = quotedValueEnd(message, valueStart);
if (quotedEnd !== undefined) {
end = quotedEnd;
continue;
}
if (attribute !== 'expires') {
end = valueStart;
while (end < message.length && !/[;\s,}\]&\r\n]/.test(message[end]!)) {
end += 1;
}
continue;
}
end = valueStart;
while (end < message.length && !/[;}\]&\r\n]/.test(message[end]!)) {
end += 1;
}
const assignments = new RegExp(ASSIGNMENT_START.source, 'gi');
assignments.lastIndex = valueStart;
const nextField = assignments.exec(message);
if (nextField && nextField.index < end) {
end = nextField.index;
}
}
return end;
}
function genericValueEnd(message: string, start: number): number {
const quotedEnd = quotedValueEnd(message, start);
if (quotedEnd !== undefined) {
return quotedEnd;
}
let end = start;
while (end < message.length && !/[\s,}\]&\r\n]/.test(message[end]!)) {
end += 1;
}
return end;
}
function authorizationValueEnd(message: string, start: number, query: boolean): number {
const quotedEnd = quotedValueEnd(message, start);
if (quotedEnd !== undefined) {
return quotedEnd;
}
if (query) {
const queryEnd = message.indexOf('&', start);
return queryEnd >= 0 ? queryEnd : message.length;
}
let end = message.length;
const structural = message.slice(start).search(/[\r\n}\]]/);
if (structural >= 0) {
end = start + structural;
}
const assignments = new RegExp(ASSIGNMENT_START.source, 'gi');
const remaining = message.slice(start, end);
for (let match = assignments.exec(remaining); match; match = assignments.exec(remaining)) {
if (isAuthorizationBoundaryKey(match[3]!)) {
end = start + match.index;
break;
}
}
return end;
}
/** Redact explicit authentication assignments in ordinary formatted logs. */
export function redactLogMessage(message: string): string {
let out = '';
let cursor = 0;
ASSIGNMENT_START.lastIndex = 0;
for (
let match = ASSIGNMENT_START.exec(message);
match;
match = ASSIGNMENT_START.exec(message)
) {
const key = match[3]!;
if (!isSensitiveKey(key)) {
ASSIGNMENT_START.lastIndex = Math.max(match.index + 1, ASSIGNMENT_START.lastIndex - 1);
continue;
}
const valueStart = ASSIGNMENT_START.lastIndex;
const valueEnd = isKeyKind(key, 'authorization')
? authorizationValueEnd(message, valueStart, match[1] === '?' || match[1] === '&')
: isKeyKind(key, 'cookie')
? cookieValueEnd(message, valueStart)
: genericValueEnd(message, valueStart);
out += message.slice(cursor, valueStart) + '***';
cursor = valueEnd;
ASSIGNMENT_START.lastIndex = valueEnd;
}
return out + message.slice(cursor);
}
/**
* Lossless nested renderer for explicit TRACE diagnostics. TRACE is an
* operator-enabled, memory-only mode and intentionally leaves values
* unredacted so a reproduction contains the complete business input.
*/
export function renderParamsVerbose(params: unknown): string {
const seen = new WeakSet<object>();
const walk = (value: unknown): string => {
if (value === null) {
return 'null';
}
if (value === undefined) {
return 'undefined';
}
switch (typeof value) {
case 'string':
return JSON.stringify(value);
case 'number':
case 'boolean':
case 'bigint':
return String(value);
case 'object': {
if (seen.has(value as object)) {
return '"[circular]"';
}
seen.add(value as object);
const out = Array.isArray(value)
? `[${value.map((item) => walk(item)).join(',')}]`
: `{${Object.entries(value as Record<string, unknown>)
.map(([key, item]) => `${key}:${walk(item)}`)
.join(',')}}`;
seen.delete(value as object);
return out;
}
default:
return typeof value;
}
};
return walk(params);
}
+453
View File
@@ -0,0 +1,453 @@
import { format } from 'util';
import { getFileTransport } from './log-file-transport';
import { redactLogMessage } from './log-summary';
import { sanitizeLogLine } from './log-sanitize';
import {
currentRequestId,
nextRequestId,
runWithoutRequestContext,
runWithRequestId,
} from './request-context';
type LogLevel = 'trace' | 'debug' | 'info' | 'success' | 'warn' | 'error';
type FileLogLevel = Exclude<LogLevel, 'trace'>;
export interface LogEntry {
id: number;
time: string;
level: LogLevel;
scope: string;
/** QQ uin, when the source logger was derived via `.child({ uin })`. */
uin?: number;
/** Correlation id, when emitted inside a `runWithRequestId(...)` scope. */
req?: number;
message: string;
line: string;
}
interface LogOptions {
scope: string;
uin?: number;
/** Free-form meta carried across `.child(...)` calls. Currently only
* `uin` is rendered, but the bag is preserved for future fields
* (requestId / traceId / etc.). */
meta?: Record<string, unknown>;
}
const UIN_SLOT_WIDTH = 12;
const LEVEL_WEIGHT: Record<LogLevel, number> = {
trace: 5,
debug: 10,
info: 20,
success: 25,
warn: 30,
error: 40,
};
const LEVEL_LABEL: Record<LogLevel, string> = {
trace: 'TRACE',
debug: 'DEBUG',
info: 'INFO',
success: 'OK',
warn: 'WARN',
error: 'ERROR',
};
const COLOR_CODE: Record<LogLevel, number> = {
trace: 90,
debug: 90,
info: 36,
success: 32,
warn: 33,
error: 31,
};
const COLOR_SCOPE = 35;
const COLOR_DIM = 2;
const COLOR_RESET = '\x1b[0m';
const MAX_LOG_ENTRIES = 1000;
/** Trace ring cap — env-tunable since trace is the high-volume stream. */
function resolveTraceBufferMax(): number {
const raw = Number.parseInt(process.env.SNOWLUMA_TRACE_BUFFER ?? '', 10);
return Number.isFinite(raw) && raw >= 100 ? raw : 5000;
}
const TRACE_BUFFER_MAX = resolveTraceBufferMax();
/**
* Fixed-capacity circular buffer. O(1) push + eviction (no array `.shift()`),
* so the high-throughput trace stream never pays an O(n) shift per overflow.
*/
class RingBuffer<T> {
private readonly buf: (T | undefined)[];
private start = 0;
private count = 0;
constructor(private readonly cap: number) {
this.buf = new Array<T | undefined>(cap);
}
push(item: T): void {
const end = (this.start + this.count) % this.cap;
this.buf[end] = item;
if (this.count < this.cap) {
this.count += 1;
} else {
this.start = (this.start + 1) % this.cap;
} // full → overwrite oldest
}
/** Read from newest to oldest without copying the retained buffer. */
newest(offset: number): T | undefined {
if (offset >= this.count) {
return undefined;
}
return this.buf[(this.start + this.count - 1 - offset) % this.cap];
}
get size(): number {
return this.count;
}
}
const logRing = new RingBuffer<LogEntry>(MAX_LOG_ENTRIES);
const traceRing = new RingBuffer<LogEntry>(TRACE_BUFFER_MAX);
const logSubscribers = new Set<(entry: LogEntry) => void>();
let nextLogId = 1;
function resolveMinLevel(): LogLevel {
const raw = (process.env.SNOWLUMA_LOG_LEVEL ?? 'info').toLowerCase();
if (
raw === 'trace' ||
raw === 'debug' ||
raw === 'info' ||
raw === 'success' ||
raw === 'warn' ||
raw === 'error'
) {
return raw;
}
return 'info';
}
function resolveFileMinLevel(): FileLogLevel {
const raw = process.env.SNOWLUMA_LOG_FILE_LEVEL;
if (raw === undefined || raw.trim() === '') {
return 'debug';
}
const normalized = raw.trim().toLowerCase();
if (
normalized === 'debug' ||
normalized === 'info' ||
normalized === 'success' ||
normalized === 'warn' ||
normalized === 'error'
) {
return normalized;
}
throw new TypeError(
'SNOWLUMA_LOG_FILE_LEVEL must be one of: debug, info, success, warn, error',
);
}
// Mutable — seeded from SNOWLUMA_LOG_LEVEL at module load, but
// setLogLevel() lets WebUI / SDK callers flip it without a restart.
// Only governs console + ring buffer + subscribers;
// the file transport level is seeded independently from
// SNOWLUMA_LOG_FILE_LEVEL (default debug)
// and is not affected by setLogLevel().
let currentLevel: LogLevel = resolveMinLevel();
const currentFileLevel: FileLogLevel = resolveFileMinLevel();
function shouldLog(level: LogLevel): boolean {
return LEVEL_WEIGHT[level] >= LEVEL_WEIGHT[currentLevel];
}
function shouldLogToFile(level: LogLevel): boolean {
return LEVEL_WEIGHT[level] >= LEVEL_WEIGHT[currentFileLevel];
}
export const LOG_LEVELS: readonly LogLevel[] = [
'trace',
'debug',
'info',
'success',
'warn',
'error',
];
export function getLogLevel(): LogLevel {
return currentLevel;
}
// Change the console / subscriber level at runtime.
// Invalid input is a no-op (returns false).
// Does not affect the file transport log level.
export function setLogLevel(level: string): boolean {
const lower = String(level).toLowerCase();
if (!LOG_LEVELS.includes(lower as LogLevel)) {
return false;
}
currentLevel = lower as LogLevel;
return true;
}
function useColor(): boolean {
if (process.env.NO_COLOR === '1') {
return false;
}
return Boolean(process.stdout.isTTY);
}
function ansi(code: number, text: string): string {
return `\x1b[${code}m${text}${COLOR_RESET}`;
}
function currentTime(): string {
const d = new Date();
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
}
function render(level: LogLevel, options: LogOptions, message: string, reqId?: number): string {
const ts = currentTime();
const label = LEVEL_LABEL[level].padEnd(5, ' ');
const uinTag = options.uin !== undefined ? `[${options.uin}]` : '';
const uinSlot = uinTag.padEnd(UIN_SLOT_WIDTH);
const reqTag = reqId !== undefined ? `[req#${reqId}]` : '';
if (!useColor()) {
return `${ts} ${label} ${uinSlot} [${options.scope}] ${reqTag ? `${reqTag} ` : ''}${message}`;
}
const cTs = ansi(COLOR_DIM, ts);
const cLabel = ansi(COLOR_CODE[level], label);
// Pad first, then color only the visible tag so escape codes don't eat
// into the alignment width.
const cUin = uinTag
? ansi(COLOR_DIM, uinTag) + ' '.repeat(Math.max(0, UIN_SLOT_WIDTH - uinTag.length))
: ' '.repeat(UIN_SLOT_WIDTH);
const cScope = ansi(COLOR_SCOPE, `[${options.scope}]`);
const cReq = reqTag ? `${ansi(COLOR_DIM, reqTag)} ` : '';
return `${cTs} ${cLabel} ${cUin} ${cScope} ${cReq}${message}`;
}
function emit(level: LogLevel, options: LogOptions, args: unknown[]): void {
// Console / subscriber and file filters are independent. If no destination
// accepts the record, stop before formatting or building a LogEntry.
const passesConsole = shouldLog(level);
const passesFile = level !== 'trace' && shouldLogToFile(level);
if (!passesConsole && !passesFile) {
return;
}
// `trace` is the high-volume full-chain diagnostic stream, intentionally
// excluded from disk to avoid I/O saturation. When console is not dialed
// to trace, the early return below skips format/render/disk entirely.
// Set SNOWLUMA_LOG_LEVEL=trace to inspect trace output in console/memory;
// trace never reaches disk regardless.
if (level === 'trace' && !passesConsole) {
return;
}
// Lazy trace form: `log.trace(() => ['…', deepRender(x)])`. The producer
// (and its expensive deep render) only runs now that trace is confirmed live.
let realArgs = args;
if (level === 'trace' && args.length === 1 && typeof args[0] === 'function') {
realArgs = (args[0] as () => unknown[])();
}
const reqId = currentRequestId();
const formattedMessage = format(...realArgs);
const message = level === 'trace' ? formattedMessage : redactLogMessage(formattedMessage);
const line = render(level, options, message, reqId);
const id = nextLogId++;
if (passesConsole) {
// File-only records are sanitized by the transport and need no memory
// entry, ISO timestamp, or second sanitized copy of the rendered line.
const entry: LogEntry = {
id,
time: new Date().toISOString(),
level,
scope: options.scope,
...(options.uin !== undefined ? { uin: options.uin } : {}),
...(reqId !== undefined ? { req: reqId } : {}),
message,
line: sanitizeLogLine(line),
};
// Trace gets its own ring so its volume never evicts the normal log buffer.
(level === 'trace' ? traceRing : logRing).push(entry);
for (const subscriber of logSubscribers) {
subscriber(entry);
}
const stream = level === 'warn' || level === 'error' ? process.stderr : process.stdout;
// Strip ASCII control characters before writing to terminal to prevent
// BEL (0x07) in user-provided strings (e.g. group member names) from
// triggering Windows system beep sounds. Exempt TAB (0x09), LF (0x0A)
// and ESC (0x1B) so multi-line records (stack traces) and ANSI color
// sequences emitted by render() pass through intact.
// eslint-disable-next-line no-control-regex
const consoleLine = line.replace(/[\x00-\x08\x0B-\x1A\x1C-\x1F\x7F]/g, '') + '\n';
try {
stream.write(consoleLine);
} catch {
// A full Docker json-file log volume can make stdout/stderr throw.
// Logging must not take down message handling or the WebUI.
}
}
// File transport sees levels >= currentFileLevel (SNOWLUMA_LOG_FILE_LEVEL,
// default debug). `trace` is memory / WebUI only (omitted here to avoid huge
// on-disk volume). ANSI stripping happens inside the transport. UIN routes
// the line to its per-account sub-file in addition to the shared one.
if (passesFile) {
getFileTransport().write(line, options.uin);
}
}
/**
* Flush and close the underlying log file. Call from shutdown hooks
* (SIGINT / SIGTERM / uncaughtException) so the WriteStream's internal
* buffer makes it to disk. Returns a promise that resolves once the OS
* has finalized the write.
*/
export function closeLogger(): Promise<void> {
return getFileTransport().close();
}
function mergeLogRings(limit = logRing.size + traceRing.size): LogEntry[] {
const take = Math.min(limit, logRing.size + traceRing.size);
const entries = new Array<LogEntry>(take);
let normalOffset = 0;
let traceOffset = 0;
// Both rings already follow increasing IDs. Merge backwards so a small
// WebUI request visits only the requested tail, even with a large TRACE ring.
for (let i = take - 1; i >= 0; i -= 1) {
const normal = logRing.newest(normalOffset);
const trace = traceRing.newest(traceOffset);
if (normal && (!trace || normal.id > trace.id)) {
entries[i] = normal;
normalOffset += 1;
} else {
entries[i] = trace!;
traceOffset += 1;
}
}
return entries;
}
export function getLogSnapshot(): LogEntry[] {
return mergeLogRings();
}
export function getRecentLogs(limit = 300): LogEntry[] {
const n = Math.max(1, Math.trunc(limit));
// Array.slice(-NaN) previously returned the full snapshot.
return mergeLogRings(Number.isNaN(n) ? undefined : n);
}
export function subscribeLogs(callback: (entry: LogEntry) => void): () => void {
logSubscribers.add(callback);
return () => {
logSubscribers.delete(callback);
};
}
export interface Logger {
/**
* High-volume, memory-only diagnostic level (never written to disk). Pass a
* lazy producer — `trace(() => ['msg %s', expensiveRender(x)])` — so the
* arguments (and any costly rendering) are built only when trace is active.
* A plain `trace('msg', x)` works too but evaluates its args eagerly.
*/
trace: (...args: unknown[]) => void;
debug: (...args: unknown[]) => void;
info: (...args: unknown[]) => void;
success: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
/**
* Derive a new logger that inherits this one's scope and carries
* additional metadata. Currently `uin` is the only key with rendering
* support (shown in the `[UIN]` slot and routed to a per-UIN file);
* other keys are preserved on the options bag for forward compat.
*/
child: (meta: { uin?: number; [k: string]: unknown }) => Logger;
}
function makeLogger(opts: LogOptions): Logger {
return {
trace: (...args: unknown[]) => emit('trace', opts, args),
debug: (...args: unknown[]) => emit('debug', opts, args),
info: (...args: unknown[]) => emit('info', opts, args),
success: (...args: unknown[]) => emit('success', opts, args),
warn: (...args: unknown[]) => emit('warn', opts, args),
error: (...args: unknown[]) => emit('error', opts, args),
child: (meta) => {
const nextUin = typeof meta.uin === 'number' ? meta.uin : opts.uin;
return makeLogger({
scope: opts.scope,
uin: nextUin,
meta: { ...(opts.meta ?? {}), ...meta },
});
},
};
}
export function createLogger(scope: string): Logger {
return makeLogger({ scope });
}
export function renderTraceBytes(body: Uint8Array): string {
return Buffer.from(body.buffer, body.byteOffset, body.byteLength).toString('hex');
}
export function runWithTraceRequest<T>(fn: () => T): T {
if (currentRequestId() !== undefined || currentLevel !== 'trace') {
return fn();
}
return runWithRequestId(nextRequestId(), fn);
}
export function logInitialWebuiCredentials(password: string): void {
// This is deliberately not a LogEntry: application logs can be retained on
// disk, exported through WebUI, and delivered to subscribers. First-run
// credentials must only cross the process terminal boundary.
const line = render(
'info',
{ scope: 'WebUI' },
format('initial credentials: user=admin password=%s', password),
);
// Keep the same terminal control-character policy as regular console logs.
// eslint-disable-next-line no-control-regex
process.stdout.write(line.replace(/[\x00-\x08\x0B-\x1A\x1C-\x1F\x7F]/g, '') + '\n');
}
// Request-correlation primitives remain available for callers that already own
// an explicit request id. New semantic entrypoints should prefer
// runWithTraceRequest() so disabled TRACE does not allocate one.
export { nextRequestId, runWithoutRequestContext, runWithRequestId, currentRequestId };
+21
View File
@@ -0,0 +1,21 @@
import { existsSync } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
export function resolveRuntimeNativePath(fileName: string): string | null {
// Bundled releases and workspace sources each own their native inputs.
for (const directory of [
path.join(moduleDir, 'native'),
path.resolve(moduleDir, '..', '..', 'runtime', 'native'),
]) {
const candidate = path.join(directory, fileName);
if (existsSync(candidate)) {
return candidate;
}
}
return null;
}
@@ -0,0 +1,14 @@
export const DEFAULT_WEBHOOK_BODY = `{
"title": "账号状态通知:{event}",
"desp": "您的账号状态发生了改变。\\n\\n**昵称**:{nickname}\\n**QQ号**:{uin}\\n**当前状态**:{event}\\n**进程状态**:{status}\\n**时间**:{time}"
}`;
export const DEFAULT_EMAIL_SUBJECT = '账号{event}:{nickname} ({uin})';
export const DEFAULT_EMAIL_BODY = `账号状态发生了改变。
昵称:{nickname}
QQ号:{uin}
当前状态:{event}
进程状态:{status}
时间:{time}`;
+11
View File
@@ -0,0 +1,11 @@
export interface SendPacketResult {
success: boolean;
gotResponse: boolean;
errorCode: number;
errorMessage: string;
responseData: Buffer | null;
}
export interface PacketSender {
sendPacket(serviceCmd: string, body: Buffer, timeoutMs?: number): Promise<SendPacketResult>;
}
+14
View File
@@ -0,0 +1,14 @@
export const CLIENT_MESSAGE_RECEIPT_CMD = 'ClientMessageReceipt';
export interface PacketInfo {
pid: number;
uin: string;
packetType?: number;
serviceCmd: string;
seqId: number;
retCode: number;
fromClient: boolean;
source?: 'client-message';
body: Uint8Array;
}
export type PacketSink = (packet: PacketInfo) => void;
+44
View File
@@ -0,0 +1,44 @@
import { AsyncLocalStorage } from 'async_hooks';
interface RequestStore {
id: number;
}
const storage = new AsyncLocalStorage<RequestStore | undefined>();
let counter = 0;
/**
* Allocate the next per-process request id (monotonic). Wraps via uint32 so
* it never overflows to a non-integer; `0` is skipped so "no id" stays
* unambiguous.
*/
export function nextRequestId(): number {
counter = (counter + 1) >>> 0;
if (counter === 0) {
counter = 1;
}
return counter;
}
/**
* Run `fn` with `id` bound as the ambient request id for the entire async
* chain it spawns. Any logger call anywhere in that chain — across packages,
* across awaits — picks it up via {@link currentRequestId} with no signature
* threading. Used by the OneBot action handler to correlate a request's whole
* journey (entry → outbound packets → exit) under one `[req#N]` tag.
*/
export function runWithRequestId<T>(id: number, fn: () => T): T {
return storage.run({ id }, fn);
}
/** Run `fn` without inheriting an ambient request id. */
export function runWithoutRequestContext<T>(fn: () => T): T {
return storage.run(undefined, fn);
}
/** The request id bound to the current async context, or undefined outside one. */
export function currentRequestId(): number | undefined {
return storage.getStore()?.id;
}
+407
View File
@@ -0,0 +1,407 @@
import fs from 'fs';
import path from 'path';
import { isObject } from './coerce';
import { normalizeUpdateSourceConfig, type UpdateSourceConfig } from './update-source';
export interface RuntimeConfig {
updateSource?: UpdateSourceConfig;
webuiPort?: number;
/** When true, every newly-discovered QQ process gets auto-injected by
* the HookManager. Also overridable at runtime via SNOWLUMA_HOOK_AUTOLOAD.
* Defaults to false; the Docker image flips it on in supervisord.conf. */
hookAutoLoad?: boolean;
/** WebUI listener bind address. '127.0.0.1' = localhost-only (default),
* '0.0.0.0' = all IPv4 interfaces. Listener-level: change needs a restart. */
webuiHost?: string;
/** WebUI TLS. Cert/key live in config/cert.pem + config/key.pem; this
* only flips serving on/off. Listener-level: change needs a restart. */
webuiTls?: { enabled?: boolean };
/** Raw trust-proxy directive (same domain as SNOWLUMA_WEBUI_TRUST_PROXY),
* consumed by the WebUI's client-ip resolver. '' = trust nobody. */
trustProxy?: string;
/** Aggregate cap for every managed file below the log root. */
logMaxTotalMb?: number;
/** Calendar-day retention. Zero disables date-based deletion. */
logRetainDays?: number;
/** Duplicate UIN-scoped lines into logs/<uin>/ in addition to the shared log. */
logPerUin?: boolean;
}
const CONFIG_DIR = 'config';
const RUNTIME_CONFIG_PATH = path.join(CONFIG_DIR, 'runtime.json');
const DEFAULT_WEBUI_PORT = 5099;
const DEFAULT_WEBUI_HOST = '127.0.0.1';
export const DEFAULT_LOG_MAX_TOTAL_MB = 1024;
export const DEFAULT_LOG_RETAIN_DAYS = 7;
export const DEFAULT_LOG_PER_UIN = false;
export const MAX_LOG_TOTAL_MB = Math.floor(Number.MAX_SAFE_INTEGER / (1024 * 1024));
export const MAX_LOG_RETAIN_DAYS = Math.floor(Number.MAX_SAFE_INTEGER / (24 * 60 * 60 * 1000));
/**
* Pure on-disk-object → typed config normalization (defaults + validation,
* no fs / no env). Exported for testing; `loadRuntimeConfig` wraps it.
*/
export function normalizeRuntimeConfig(parsed: unknown): RuntimeConfig {
const obj = isObject(parsed) ? parsed : {};
return {
...(obj.updateSource === undefined
? {}
: { updateSource: normalizeUpdateSourceConfig(obj.updateSource) }),
webuiPort: normalizePort(obj.webuiPort ?? DEFAULT_WEBUI_PORT, DEFAULT_WEBUI_PORT),
hookAutoLoad: normalizeBool(obj.hookAutoLoad, false),
webuiHost: normalizeHost(obj.webuiHost),
webuiTls: {
enabled: isObject(obj.webuiTls) ? normalizeBool(obj.webuiTls.enabled, false) : false,
},
trustProxy: typeof obj.trustProxy === 'string' ? obj.trustProxy : '',
logMaxTotalMb: normalizeRequiredInteger(
obj.logMaxTotalMb,
DEFAULT_LOG_MAX_TOTAL_MB,
1,
MAX_LOG_TOTAL_MB,
'logMaxTotalMb',
),
logRetainDays: normalizeRequiredInteger(
obj.logRetainDays,
DEFAULT_LOG_RETAIN_DAYS,
0,
MAX_LOG_RETAIN_DAYS,
'logRetainDays',
),
logPerUin: normalizeRequiredBool(obj.logPerUin, DEFAULT_LOG_PER_UIN, 'logPerUin'),
};
}
/**
* Pure SNOWLUMA_* env → override patch (no fs). Env wins over runtime.json
* (a trusted launcher like SnowLumaDesktop pins these per-launch without
* rewriting the file). Absent vars produce no key.
*/
export function resolveRuntimeEnvOverrides(env: NodeJS.ProcessEnv): Partial<RuntimeConfig> {
const out: Partial<RuntimeConfig> = {};
const port = parsePortString(env.SNOWLUMA_WEBUI_PORT);
if (port !== undefined) {
out.webuiPort = port;
}
const host = env.SNOWLUMA_WEBUI_HOST;
if (typeof host === 'string' && host.trim()) {
out.webuiHost = host.trim();
}
// Present-but-"0"/"off" is a deliberate override (trust nobody), distinct
// from "absent" — so gate on key presence, not truthiness.
const tp = env.SNOWLUMA_WEBUI_TRUST_PROXY;
if (typeof tp === 'string') {
out.trustProxy = tp;
}
const logMaxTotalMb = parseRequiredIntegerEnv(
env.SNOWLUMA_LOG_MAX_TOTAL_MB,
1,
MAX_LOG_TOTAL_MB,
'SNOWLUMA_LOG_MAX_TOTAL_MB',
);
if (logMaxTotalMb !== undefined) {
out.logMaxTotalMb = logMaxTotalMb;
}
const logRetainDays = parseRequiredIntegerEnv(
env.SNOWLUMA_LOG_RETAIN_DAYS,
0,
MAX_LOG_RETAIN_DAYS,
'SNOWLUMA_LOG_RETAIN_DAYS',
);
if (logRetainDays !== undefined) {
out.logRetainDays = logRetainDays;
}
const logPerUin = parseRequiredBoolEnv(env.SNOWLUMA_LOG_PER_UIN, 'SNOWLUMA_LOG_PER_UIN');
if (logPerUin !== undefined) {
out.logPerUin = logPerUin;
}
const webuiTls = parseRequiredBoolEnv(env.SNOWLUMA_WEBUI_TLS, 'SNOWLUMA_WEBUI_TLS');
if (webuiTls !== undefined) {
out.webuiTls = { enabled: webuiTls };
}
return out;
}
export function loadRuntimeConfig(): RuntimeConfig {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
const parsed = tryLoadRuntimeConfig();
const normalized = normalizeRuntimeConfig(parsed ?? {});
// Persist when the file is absent or when normalization changed/backfilled
// anything (e.g. an older runtime.json lacking webuiHost/webuiTls).
if (parsed === null || !sameRuntimeConfig(parsed, normalized)) {
saveRuntimeConfig(normalized);
}
// Env overrides apply in-memory only — never written back to disk.
return { ...normalized, ...resolveRuntimeEnvOverrides(process.env) };
}
/**
* Read the persisted config (normalized, no env overrides, no write). For the
* settings panel's GET — shows what's actually saved/editable on disk.
*/
export function readRuntimeConfig(): RuntimeConfig {
return normalizeRuntimeConfig(tryLoadRuntimeConfig() ?? {});
}
/**
* Persist a partial update. Merges onto the ON-DISK config (not the env-merged
* runtime view) so an env override (e.g. SNOWLUMA_WEBUI_PORT) is never baked
* into runtime.json. Returns the new persisted config (without env overrides).
*/
export function updateRuntimeConfig(patch: Partial<RuntimeConfig>): RuntimeConfig {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
const onDisk = normalizeRuntimeConfig(tryLoadRuntimeConfig() ?? {});
const next = normalizeRuntimeConfig({ ...onDisk, ...patch });
saveRuntimeConfig(next);
return next;
}
function tryLoadRuntimeConfig(): Record<string, unknown> | null {
if (!fs.existsSync(RUNTIME_CONFIG_PATH)) {
return null;
}
try {
const parsed = JSON.parse(fs.readFileSync(RUNTIME_CONFIG_PATH, 'utf8')) as unknown;
return isObject(parsed) ? parsed : null;
} catch {
return null;
}
}
function saveRuntimeConfig(config: RuntimeConfig): void {
const temporaryPath = `${RUNTIME_CONFIG_PATH}.tmp-${String(process.pid)}`;
try {
fs.writeFileSync(temporaryPath, JSON.stringify(config, null, 2), 'utf8');
fs.renameSync(temporaryPath, RUNTIME_CONFIG_PATH);
} catch (error) {
try {
fs.unlinkSync(temporaryPath);
} catch (cleanupError) {
if (!isErrnoCode(cleanupError, 'ENOENT')) {
throw new AggregateError(
[error, cleanupError],
'failed to persist runtime config and remove its temporary file',
);
}
}
throw error;
}
}
/** True when the raw on-disk object already matches the normalized config
* on every known field (so we can skip a needless rewrite). */
function sameRuntimeConfig(parsed: Record<string, unknown>, n: RuntimeConfig): boolean {
const parsedTls = isObject(parsed.webuiTls) ? parsed.webuiTls.enabled : undefined;
return (
parsed.webuiPort === n.webuiPort &&
parsed.hookAutoLoad === n.hookAutoLoad &&
parsed.webuiHost === n.webuiHost &&
parsedTls === n.webuiTls?.enabled &&
parsed.trustProxy === n.trustProxy &&
parsed.logMaxTotalMb === n.logMaxTotalMb &&
parsed.logRetainDays === n.logRetainDays &&
parsed.logPerUin === n.logPerUin &&
JSON.stringify(parsed.updateSource) === JSON.stringify(n.updateSource)
);
}
function parsePortString(raw: unknown): number | undefined {
if (typeof raw !== 'string' || !raw.trim()) {
return undefined;
}
const n = Number(raw.trim());
if (!Number.isFinite(n)) {
return undefined;
}
const port = Math.trunc(n);
if (port <= 0 || port > 65535) {
return undefined;
}
return port;
}
function normalizeHost(value: unknown): string {
if (typeof value === 'string' && value.trim()) {
return value.trim();
}
return DEFAULT_WEBUI_HOST;
}
function normalizePort(value: unknown, fallback: number): number {
if (typeof value === 'number' && Number.isFinite(value)) {
const n = Math.trunc(value);
if (n > 0 && n <= 65535) {
return n;
}
return fallback;
}
if (typeof value === 'string' && value.trim()) {
const n = Number(value);
if (Number.isFinite(n)) {
const port = Math.trunc(n);
if (port > 0 && port <= 65535) {
return port;
}
}
}
return fallback;
}
function normalizeRequiredInteger(
value: unknown,
fallback: number,
min: number,
max: number,
field: string,
): number {
if (value === undefined) {
return fallback;
}
const n = typeof value === 'string' && value.trim() ? Number(value.trim()) : value;
if (typeof n === 'number' && Number.isSafeInteger(n) && n >= min && n <= max) {
return n;
}
throw new RangeError(`${field} must be an integer in ${String(min)}..${String(max)}`);
}
function normalizeRequiredBool(value: unknown, fallback: boolean, field: string): boolean {
if (value === undefined) {
return fallback;
}
const parsed = parseBoolValue(value);
if (parsed !== undefined) {
return parsed;
}
throw new TypeError(`${field} must be a boolean`);
}
function normalizeBool(value: unknown, fallback: boolean): boolean {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value !== 0;
}
if (typeof value === 'string') {
const v = value.trim().toLowerCase();
if (v === 'true' || v === '1' || v === 'yes' || v === 'on') {
return true;
}
if (v === 'false' || v === '0' || v === 'no' || v === 'off' || v === '') {
return false;
}
}
return fallback;
}
function parseRequiredIntegerEnv(
raw: unknown,
min: number,
max: number,
field: string,
): number | undefined {
if (typeof raw !== 'string' || !raw.trim()) {
return undefined;
}
const n = Number(raw.trim());
if (Number.isSafeInteger(n) && n >= min && n <= max) {
return n;
}
throw new RangeError(`${field} must be an integer in ${String(min)}..${String(max)}`);
}
function parseRequiredBoolEnv(raw: unknown, field: string): boolean | undefined {
if (typeof raw !== 'string' || !raw.trim()) {
return undefined;
}
const parsed = parseBoolValue(raw);
if (parsed !== undefined) {
return parsed;
}
throw new TypeError(`${field} must be a boolean`);
}
function parseBoolValue(raw: unknown): boolean | undefined {
if (typeof raw === 'boolean') {
return raw;
}
if (raw === 1) {
return true;
}
if (raw === 0) {
return false;
}
if (typeof raw !== 'string') {
return undefined;
}
const value = raw.trim().toLowerCase();
if (value === 'true' || value === '1' || value === 'yes' || value === 'on') {
return true;
}
if (value === 'false' || value === '0' || value === 'no' || value === 'off') {
return false;
}
return undefined;
}
function isErrnoCode(error: unknown, code: string): boolean {
return error instanceof Error && 'code' in error && error.code === code;
}
+28
View File
@@ -0,0 +1,28 @@
import parse from 'semver/functions/parse.js';
/** Parse a canonical SemVer string; tag prefixes belong at the GitHub boundary. */
export function parseVersion(value: unknown) {
if (typeof value !== 'string') {
return null;
}
const parsed = parse(value);
if (!parsed) {
return null;
}
const canonical = parsed.version + (parsed.build.length ? `+${parsed.build.join('.')}` : '');
return value === canonical ? parsed : null;
}
export function compareVersions(a: string, b: string): number {
const left = parseVersion(a);
const right = parseVersion(b);
if (!left || !right) {
throw new TypeError('Invalid semantic version');
}
return left.compare(right);
}
+157
View File
@@ -0,0 +1,157 @@
/** TTL caching and request deduplication to limit repeated upstream requests. */
export interface SingleFlightCacheOptions<K, V> {
/** Freshness window in ms. Expired values are released even while idle. */
ttlMs: number;
/** Load the value for a key. Rejections propagate and are never cached. */
load: (key: K) => Promise<V>;
/**
* Decide whether a resolved value should be cached. Default: always.
* Return `false` to leave an entry uncached (e.g. an error-shaped result the
* next call should retry) while still de-duping concurrent in-flight callers.
*/
shouldCache?: (value: V) => boolean;
/** Clock, injectable for tests. Defaults to `Date.now`. */
now?: () => number;
}
export class SingleFlightCache<K, V> {
private readonly ttlMs: number;
private readonly load: (key: K) => Promise<V>;
private readonly shouldCache: (value: V) => boolean;
private readonly now: () => number;
private readonly fresh = new Map<K, { at: number; value: V }>();
private readonly inflight = new Map<K, Promise<V>>();
private expiryTimer: ReturnType<typeof setTimeout> | undefined;
private disposed = false;
constructor(opts: SingleFlightCacheOptions<K, V>) {
this.ttlMs = opts.ttlMs;
this.load = opts.load;
this.shouldCache = opts.shouldCache ?? (() => true);
this.now = opts.now ?? Date.now;
}
/** Number of retained values; pending loads are not counted. */
get size(): number {
return this.fresh.size;
}
/**
* Get the value for `key`.
*
* - Returns a fresh cached value when the last load was within `ttlMs`.
* - Otherwise de-dupes concurrent callers onto a single `load()` and caches
* the result (subject to `shouldCache`). A rejected `load` propagates to
* every joined caller, clears the in-flight slot, and is not cached.
* - `force` bypasses the freshness check but still joins an in-flight load
* rather than starting a second one.
*/
get(key: K, opts: { force?: boolean } = {}): Promise<V> {
if (this.disposed) {
return Promise.reject(new Error('SingleFlightCache is disposed'));
}
const hit = this.fresh.get(key);
if (hit) {
if (this.now() - hit.at >= this.ttlMs) {
this.fresh.delete(key);
this.cancelExpiryIfEmpty();
} else if (!opts.force) {
return Promise.resolve(hit.value);
}
}
const existing = this.inflight.get(key);
if (existing) {
return existing;
}
const load = async (): Promise<V> => {
const value = await this.load(key);
if (this.inflight.get(key) === task && this.ttlMs > 0 && this.shouldCache(value)) {
// Keep insertion order aligned with expiry, including refreshes.
this.fresh.delete(key);
this.fresh.set(key, { at: this.now(), value });
this.scheduleExpiry();
}
return value;
};
const task = load().finally(() => {
if (this.inflight.get(key) === task) {
this.inflight.delete(key);
}
});
this.inflight.set(key, task);
return task;
}
/** Detach the entry and its pending load; existing callers still settle. */
invalidate(key: K): void {
this.fresh.delete(key);
this.inflight.delete(key);
this.cancelExpiryIfEmpty();
}
/** Detach all entries and pending loads without aborting existing callers. */
clear(): void {
this.fresh.clear();
this.inflight.clear();
this.cancelExpiryIfEmpty();
}
/** Release this owner's cache permanently; pending callers still settle. */
dispose(): void {
this.disposed = true;
this.clear();
}
private cancelExpiryIfEmpty(): void {
if (this.fresh.size === 0 && this.expiryTimer !== undefined) {
clearTimeout(this.expiryTimer);
this.expiryTimer = undefined;
}
}
private scheduleExpiry(): void {
if (this.expiryTimer !== undefined || !Number.isFinite(this.ttlMs)) {
return;
}
const oldest = this.fresh.values().next().value;
if (!oldest) {
return;
}
// One unref'ed timer per cache, not one closure/timer per cached roster.
// Clamp long TTLs to Node's timer range rather than overflowing to 1ms.
const delay = Math.max(1, Math.min(2 ** 31 - 1, oldest.at + this.ttlMs - this.now()));
this.expiryTimer = setTimeout(() => {
this.expiryTimer = undefined;
const now = this.now();
for (const [key, entry] of this.fresh) {
if (now - entry.at < this.ttlMs) {
break;
}
this.fresh.delete(key);
}
this.scheduleExpiry();
}, delay);
this.expiryTimer.unref();
}
}
export function createSingleFlightCache<K, V>(
opts: SingleFlightCacheOptions<K, V>,
): SingleFlightCache<K, V> {
return new SingleFlightCache(opts);
}
+11
View File
@@ -0,0 +1,11 @@
// Shared QQ UIN validity check. A "real" UIN is a live QQ account number, not
// a garbage value the native hook can emit during the probe→inject race — e.g.
// a 13-digit millisecond timestamp read from not-yet-populated QQ memory
// (issue #162). No real QQ UIN exceeds 10 digits (they fit in uint32, max
// 4294967295), so a pure digit string of 5..10 chars is the accept window.
//
// Callers rely on a `true` result guaranteeing a BigInt()-safe pure-digit
// string. Keep that contract if the pattern ever changes.
export function isRealUin(uin: string): boolean {
return /^\d{5,10}$/.test(uin);
}
+60
View File
@@ -0,0 +1,60 @@
export interface UpdateSourceConfig {
mode: 'auto' | 'direct' | 'mirror';
mirror: string;
mirrors: string[];
}
export function normalizeMirrorUrl(value: unknown): string {
if (typeof value !== 'string' || value.length > 2048) {
throw new Error('镜像地址必须是 HTTP(S) URL');
}
let url: URL;
try {
url = new URL(value.trim());
} catch {
throw new Error('镜像地址必须是 HTTP(S) URL');
}
if (
!['http:', 'https:'].includes(url.protocol) ||
url.username ||
url.password ||
url.search ||
url.hash
) {
throw new Error('镜像地址仅支持 HTTP(S),不能包含账号、密码、查询参数或片段');
}
return url.href.replace(/\/+$/, '');
}
export function normalizeUpdateSourceConfig(value: unknown): UpdateSourceConfig {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('更新来源必须是对象');
}
if (
!('mode' in value) ||
(value.mode !== 'auto' && value.mode !== 'direct' && value.mode !== 'mirror')
) {
throw new Error('更新来源模式无效');
}
if (!('mirrors' in value) || !Array.isArray(value.mirrors) || value.mirrors.length > 16) {
throw new Error('自动筛选最多支持 16 个镜像');
}
const mirror = 'mirror' in value && value.mirror !== '' ? normalizeMirrorUrl(value.mirror) : '';
if (value.mode === 'mirror' && !mirror) {
throw new Error('请指定镜像地址');
}
return {
mode: value.mode,
mirror,
mirrors: [...new Set(value.mirrors.map(normalizeMirrorUrl))],
};
}
+3
View File
@@ -0,0 +1,3 @@
import manifest from '../../../package.json' with { type: 'json' };
export const APP_VERSION = manifest.version;
+100
View File
@@ -0,0 +1,100 @@
/** Browser-safe contract shared by startup, the WebUI and embedding hosts.
* Targets are deployment choices; they never belong in the saved UI preferences. */
export const WEBUI_TARGETS = {
maibot: {
label: '麦麦 / 一键包',
styles: ['millennium'],
defaultStyle: 'millennium',
},
} as const;
export type WebuiTarget = keyof typeof WEBUI_TARGETS;
export type WebuiColorMode = 'light' | 'dark' | 'system';
export type WebuiPresentation = {
[T in WebuiTarget]: {
version: 1;
target: T;
style: (typeof WEBUI_TARGETS)[T]['styles'][number];
mode: WebuiColorMode;
};
}[WebuiTarget];
/** null releases the override for this document, without changing deployment opt-in. */
export type WebuiPresentationUpdate = WebuiPresentation | { version: 1; target: null };
export const WEBUI_PRESENTATION_EVENT = 'snowluma:host-appearance';
export const WEBUI_TARGET_BOOTSTRAP_ID = 'snowluma-webui-target';
export function resolveWebuiTarget(raw: string | undefined): WebuiTarget | null {
if (raw === undefined || raw.trim() === '') {
return null;
}
const value = raw.trim();
if (!Object.hasOwn(WEBUI_TARGETS, value)) {
throw new Error(
`Invalid SNOWLUMA_WEBUI_TARGET: ${JSON.stringify(value)}. Expected ${Object.keys(WEBUI_TARGETS).join(', ')} or an empty value.`,
);
}
return value as WebuiTarget;
}
export function defaultWebuiPresentation(target: WebuiTarget): WebuiPresentation {
return { version: 1, target, style: WEBUI_TARGETS[target].defaultStyle, mode: 'system' };
}
export function parseWebuiPresentation(value: unknown): WebuiPresentationUpdate {
if (
!value ||
typeof value !== 'object' ||
!('version' in value) ||
value.version !== 1 ||
!('target' in value)
) {
throw new TypeError('Invalid host appearance: expected a version 1 presentation');
}
if (value.target === null) {
return { version: 1, target: null };
}
if (typeof value.target !== 'string' || !Object.hasOwn(WEBUI_TARGETS, value.target)) {
throw new TypeError('Invalid host appearance target');
}
const target = value.target as WebuiTarget;
const styles: readonly string[] = WEBUI_TARGETS[target].styles;
if (!('style' in value) || typeof value.style !== 'string' || !styles.includes(value.style)) {
throw new TypeError(`Invalid host appearance style for ${target}`);
}
if (
!('mode' in value) ||
(value.mode !== 'light' && value.mode !== 'dark' && value.mode !== 'system')
) {
throw new TypeError('Invalid host appearance color mode');
}
// Both discriminants have been checked against the same target registry.
return { version: 1, target, style: value.style, mode: value.mode } as WebuiPresentation;
}
/** Inject before the cached pre-paint theme script. Default HTML stays byte-for-byte intact. */
export function injectWebuiTarget(html: string, target: WebuiTarget | null): string {
if (target === null) {
return html;
}
if (!html.includes('<head>')) {
throw new Error('WebUI HTML is missing <head>; cannot inject runtime target');
}
const json = JSON.stringify(defaultWebuiPresentation(target)).replaceAll('<', '\\u003c');
return html.replace(
'<head>',
`<head>\n <script id="${WEBUI_TARGET_BOOTSTRAP_ID}" type="application/json">${json}</script>`,
);
}
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import {
ACCESS_TOKEN_MIN_LENGTH,
ACCESS_TOKEN_MIN_SCORE,
assessAccessToken,
} from '../src/access-token';
describe('access token strength', () => {
it('requires the configured minimum length', () => {
expect(assessAccessToken('a'.repeat(ACCESS_TOKEN_MIN_LENGTH - 1))).toMatchObject({
acceptable: false,
reason: 'too-short',
});
});
it('scores system and user-specific words as guessable', () => {
expect(
assessAccessToken('SnowLumaSnowLuma', ['SnowLuma', 'OneBot', '10001']),
).toMatchObject({
acceptable: false,
reason: 'guessable',
});
});
it('accepts a sufficiently long, hard-to-guess token', () => {
const result = assessAccessToken('E5xqVb7_9pLt2QwR4M8kZ1nY', ['SnowLuma', 'OneBot']);
expect(result.acceptable).toBe(true);
expect(result.reason).toBe('acceptable');
expect(result.score).toBeGreaterThanOrEqual(ACCESS_TOKEN_MIN_SCORE);
});
});
+78
View File
@@ -0,0 +1,78 @@
import { describe, it, expect } from 'vitest';
import { isObject, boolOr, clampNum, clampInt } from '../src/coerce';
describe('isObject', () => {
it('is true only for plain non-null non-array objects', () => {
expect(isObject({})).toBe(true);
expect(isObject({ a: 1 })).toBe(true);
expect(isObject(null)).toBe(false);
expect(isObject([])).toBe(false);
expect(isObject('x')).toBe(false);
expect(isObject(42)).toBe(false);
expect(isObject(undefined)).toBe(false);
});
});
describe('boolOr', () => {
it('returns the value only when it is a real boolean, else the fallback', () => {
expect(boolOr(true, false)).toBe(true);
expect(boolOr(false, true)).toBe(false);
expect(boolOr('true', false)).toBe(false); // strings are NOT coerced
expect(boolOr(1, false)).toBe(false);
expect(boolOr(undefined, true)).toBe(true);
expect(boolOr(null, true)).toBe(true);
});
});
describe('clampNum', () => {
it('passes finite numbers through, clamped to [min,max]', () => {
expect(clampNum(5, 0, 10, -1)).toBe(5);
expect(clampNum(-3, 0, 10, -1)).toBe(0); // below min
expect(clampNum(99, 0, 10, -1)).toBe(10); // above max
});
it('coerces numeric strings', () => {
expect(clampNum('7', 0, 10, -1)).toBe(7);
expect(clampNum('7.5', 0, 10, -1)).toBe(7.5); // NOT truncated (that is clampInt)
});
it('falls back on non-finite / non-numeric input', () => {
expect(clampNum('abc', 0, 10, 3)).toBe(3);
expect(clampNum(NaN, 0, 10, 3)).toBe(3);
expect(clampNum(Infinity, 0, 10, 3)).toBe(3);
expect(clampNum(undefined, 0, 10, 3)).toBe(3);
expect(clampNum(null, 0, 10, 3)).toBe(3);
expect(clampNum({}, 0, 10, 3)).toBe(3);
});
it('preserves fractional precision within bounds', () => {
expect(clampNum(0.42, 0, 1, 0)).toBe(0.42);
});
});
describe('clampInt', () => {
it('is clampNum truncated toward zero', () => {
expect(clampInt(7.9, 0, 10, -1)).toBe(7);
expect(clampInt('7.9', 0, 10, -1)).toBe(7);
expect(clampInt(5, 0, 10, -1)).toBe(5);
});
it('clamps before truncating', () => {
expect(clampInt(99.9, 0, 10, -1)).toBe(10);
expect(clampInt(-3.5, 0, 10, -1)).toBe(0);
});
it('clamp-then-trunc ordering: a fractional lower bound is crossed by the clamp, not the trunc', () => {
// Distinguishes clamp→trunc (this contract) from trunc→clamp: input 2.4
// with min 2.5 → clamp lifts to 2.5, then trunc → 2. A trunc-first impl
// would floor to 2 then clamp up to 2.5. Pins the order for the shared
// primitive (future callers may use fractional bounds).
expect(clampInt(2.4, 2.5, 10, 0)).toBe(2);
});
it('falls back on non-finite input', () => {
expect(clampInt('abc', 0, 10, 4)).toBe(4);
expect(clampInt(NaN, 0, 10, 4)).toBe(4);
expect(clampInt(undefined, 0, 10, 4)).toBe(4);
});
});
+81
View File
@@ -0,0 +1,81 @@
import { describe, it, expect } from 'vitest';
import { mapWithConcurrency } from '../src/concurrency';
const tick = () => new Promise<void>((r) => setTimeout(r, 0));
describe('mapWithConcurrency', () => {
it('returns results in input order', async () => {
const out = await mapWithConcurrency([1, 2, 3, 4, 5], 2, async (n) => n * 10);
expect(out).toEqual([10, 20, 30, 40, 50]);
});
it('passes the absolute index (across batch boundaries)', async () => {
const seen: Array<[number, number]> = [];
await mapWithConcurrency(['a', 'b', 'c', 'd', 'e'], 2, async (item, i) => {
seen.push([i, item.charCodeAt(0)]);
return i;
});
expect(seen.map(([i]) => i).sort((x, y) => x - y)).toEqual([0, 1, 2, 3, 4]);
});
it('never runs more than `concurrency` at once (batched), and serializes across batches', async () => {
let inFlight = 0;
let peak = 0;
const order: number[] = [];
await mapWithConcurrency([0, 1, 2, 3, 4, 5, 6], 3, async (n) => {
inFlight++;
peak = Math.max(peak, inFlight);
await tick();
order.push(n);
inFlight--;
return n;
});
expect(peak).toBeLessThanOrEqual(3);
// batched: first 3 (0,1,2) settle before the next batch (3,4,5) starts.
expect(order.slice(0, 3).sort((a, b) => a - b)).toEqual([0, 1, 2]);
expect(order.slice(3, 6).sort((a, b) => a - b)).toEqual([3, 4, 5]);
expect(order[6]).toBe(6);
});
it('handles a length not divisible by concurrency (short final batch)', async () => {
const out = await mapWithConcurrency([1, 2, 3, 4, 5], 3, async (n) => n);
expect(out).toEqual([1, 2, 3, 4, 5]);
});
it('runs everything in one batch when concurrency >= length', async () => {
let peak = 0,
inFlight = 0;
const out = await mapWithConcurrency([1, 2, 3], 10, async (n) => {
inFlight++;
peak = Math.max(peak, inFlight);
await tick();
inFlight--;
return n;
});
expect(out).toEqual([1, 2, 3]);
expect(peak).toBe(3);
});
it('returns [] for an empty input without calling fn', async () => {
let called = 0;
const out = await mapWithConcurrency([], 4, async (n) => {
called++;
return n;
});
expect(out).toEqual([]);
expect(called).toBe(0);
});
it('treats concurrency < 1 as 1 (fully serial)', async () => {
let peak = 0,
inFlight = 0;
await mapWithConcurrency([1, 2, 3], 0, async (n) => {
inFlight++;
peak = Math.max(peak, inFlight);
await tick();
inFlight--;
return n;
});
expect(peak).toBe(1);
});
});
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { positiveIntEnv } from '../src/env';
describe('positiveIntEnv', () => {
it('returns the fallback when the variable is absent or blank', () => {
expect(positiveIntEnv('SNOWLUMA_TEST_INT', 15, { env: {} })).toBe(15);
expect(positiveIntEnv('SNOWLUMA_TEST_INT', 15, { env: { SNOWLUMA_TEST_INT: ' ' } })).toBe(
15,
);
});
it('reads a positive integer and honors the max', () => {
expect(
positiveIntEnv('SNOWLUMA_TEST_INT', 15, { env: { SNOWLUMA_TEST_INT: '2500' } }),
).toBe(2500);
expect(
positiveIntEnv('SNOWLUMA_TEST_INT', 15, {
max: 65535,
env: { SNOWLUMA_TEST_INT: '80' },
}),
).toBe(80);
});
it('ignores zero, negatives, fractions, and values above the max', () => {
for (const raw of ['0', '-5', '1.5', 'nope', '70000']) {
expect(
positiveIntEnv('SNOWLUMA_TEST_INT', 15, {
max: 65535,
env: { SNOWLUMA_TEST_INT: raw },
}),
).toBe(15);
}
});
});
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { fromHex, hexPreview, toHex, toHexUpper } from '../src/hex';
describe('hex', () => {
it('encodes only the selected byte view', () => {
const bytes = Uint8Array.of(0xff, 0x01, 0xab, 0xff);
expect(toHex(bytes.subarray(1, 3))).toBe('01ab');
expect(toHexUpper(Buffer.from(bytes).subarray(1, 3))).toBe('01AB');
});
it('toHex / toHexUpper render bytes as zero-padded hex', () => {
const b = Uint8Array.from([0x00, 0x0f, 0xa0, 0xff]);
expect(toHex(b)).toBe('000fa0ff');
expect(toHexUpper(b)).toBe('000FA0FF');
expect(toHex(Uint8Array.of())).toBe('');
});
it('hexPreview appends "..." only past maxBytes', () => {
const b = Uint8Array.from([1, 2, 3, 4, 5]);
expect(hexPreview(b)).toBe('0102030405'); // under the default 64-byte cap
expect(hexPreview(b, 5)).toBe('0102030405'); // exactly at the cap → no ellipsis
expect(hexPreview(b, 2)).toBe('0102...'); // truncated
expect(hexPreview(b, 0)).toBe('...'); // nothing shown, but bytes remain
});
it('fromHex round-trips toHex and rejects odd-length input', () => {
const b = Buffer.from([0xde, 0xad, 0xbe, 0xef]);
expect(fromHex(toHex(b)).equals(b)).toBe(true);
expect(toHex(fromHex('00ffa0'))).toBe('00ffa0');
expect(() => fromHex('abc')).toThrow();
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest';
import { execFileSync } from 'node:child_process';
import { sanitizeLogLine } from '../src/log-sanitize';
describe('sanitizeLogLine', () => {
it('removes SGR styling in seven-bit and eight-bit forms', () => {
expect(sanitizeLogLine('\x1b[31mred\x1b[0m / \u009b32mgreen\u009b0m')).toBe('red / green');
});
it('removes OSC hyperlinks without removing their visible label', () => {
expect(sanitizeLogLine('\x1b]8;;https://example.com\x07visible\x1b]8;;\x07')).toBe(
'visible',
);
});
it('removes terminal control-string payloads', () => {
expect(sanitizeLogLine('before\x1bPprivate payload\x1b\\after')).toBe('beforeafter');
});
it('preserves tabs and newlines while removing other controls', () => {
expect(sanitizeLogLine('one\ttwo\nthree\r\x00')).toBe('one\ttwo\nthree');
});
it('does not leak unterminated terminal strings', () => {
expect(sanitizeLogLine('visible\x1b]8;;https://example.com')).toBe('visible');
});
it('preserves long Unicode records and adjacent visible spans', () => {
const body = '消息 🌌 e\u0301\tpacket=0011aabb\n'.repeat(1000);
expect(sanitizeLogLine(body)).toBe(body);
expect(sanitizeLogLine(`\x1b[32m${body}\x1b[0m\x00tail\x1b`)).toBe(`${body}tail`);
});
it.each(['\x1bP', '\x1bX', '\x1b^', '\x1b_', '\u0090', '\u0098', '\u009e', '\u009f'])(
'removes %j control strings between visible spans',
(opener) => {
expect(sanitizeLogLine(`before${opener}private\u009cafter`)).toBe('beforeafter');
expect(sanitizeLogLine(`before${opener}private\x1b\\after`)).toBe('beforeafter');
expect(sanitizeLogLine(`before${opener}private`)).toBe('before');
},
);
it('handles consecutive controls and records consisting only of controls', () => {
expect(sanitizeLogLine('\x00\x1b[31m\u009b0m\r\u007f')).toBe('');
expect(sanitizeLogLine('a\x00\x1b[31m\u009b0m\r\u007fb')).toBe('ab');
expect(sanitizeLogLine('')).toBe('');
});
it('preserves every visible UTF-16 code unit when removing a large hidden payload', () => {
const visible = 'visible 🌌 e\u0301\ud800x\udc00';
expect(sanitizeLogLine(`${visible}\x1b]${'hidden'.repeat(1000)}`)).toBe(visible);
});
it('does not retain discarded control-string payloads through a short visible span', () => {
const moduleUrl = new URL('../src/log-sanitize.ts', import.meta.url).href;
const output = execFileSync(
process.execPath,
[
'--expose-gc',
'--input-type=module',
'-e',
`
import { setImmediate } from 'node:timers/promises';
import { sanitizeLogLine } from ${JSON.stringify(moduleUrl)};
async function used() {
await setImmediate();
global.gc();
global.gc();
return process.memoryUsage().heapUsed;
}
function record(i) {
return sanitizeLogLine('visible record ' + i + '\\x1b]' + 'x'.repeat(1024 * 1024));
}
const before = await used();
const records = Array.from({ length: 16 }, (_, i) => record(i));
const retained = (await used()) - before;
console.log(JSON.stringify({ retained, records }));
`,
],
{ encoding: 'utf8', timeout: 10_000 },
);
const result = JSON.parse(output);
expect(result.records).toEqual(Array.from({ length: 16 }, (_, i) => `visible record ${i}`));
// Leave ample GC/runtime overhead while rejecting retention of the 16 MiB input.
expect(result.retained).toBeLessThan(4 * 1024 * 1024);
});
});
+230
View File
@@ -0,0 +1,230 @@
import { describe, expect, it } from 'vitest';
import { redactLogMessage, renderParamsVerbose, summarizeParams } from '../src/log-summary';
describe('summarizeParams', () => {
it('renders null/undefined as {}', () => {
expect(summarizeParams(null)).toBe('{}');
expect(summarizeParams(undefined)).toBe('{}');
});
it('renders a top-level non-object via String() WITHOUT quotes', () => {
// The reported change-request: a top-level primitive is NOT JSON-quoted.
expect(summarizeParams('hello')).toBe('hello');
expect(summarizeParams(123)).toBe('123');
});
it('collapses a top-level array to [len=N]', () => {
expect(summarizeParams([1, 2, 3])).toBe('[len=3]');
});
it('quotes string fields, collapses nested values, truncates long strings', () => {
expect(summarizeParams({ a: 1, b: 'x', c: { d: 1 }, e: [1, 2] })).toBe(
'a=1 b="x" c={...} e=[len=2]',
);
expect(summarizeParams({ s: 'x'.repeat(50) })).toBe(`s="${'x'.repeat(40)}..."`);
});
it('redacts authentication fields without exposing their values', () => {
const out = summarizeParams({
token: 'token-value',
Cookie: 'cookie-value',
Authorization: 'Bearer authorization-value',
user_password: 'password-value',
apiKey: 'key-value',
group_id: 12345,
});
expect(out).toContain('token="***"');
expect(out).toContain('Cookie="***"');
expect(out).toContain('Authorization="***"');
expect(out).toContain('user_password="***"');
expect(out).toContain('apiKey="***"');
expect(out).toContain('group_id=12345');
expect(out).not.toMatch(
/token-value|cookie-value|authorization-value|password-value|key-value/,
);
});
it('does not treat sensitive substrings inside ordinary keys as credentials', () => {
expect(
summarizeParams({
retoken: 'visible',
cookietown: 'visible',
myauthorization: 'visible',
}),
).toBe('retoken="visible" cookietown="visible" myauthorization="visible"');
});
it('caps total output and appends "..." on overflow', () => {
const big: Record<string, number> = {};
for (let i = 0; i < 60; i++) {
big['key' + i] = i;
}
const out = summarizeParams(big);
expect(out.startsWith('key0=0')).toBe(true);
expect(out.endsWith('...')).toBe(true);
expect(out.length).toBeLessThanOrEqual(200);
});
it('caps a single oversized first key and top-level primitive', () => {
const oversizedKey = 'k'.repeat(300);
const objectOut = summarizeParams({ [oversizedKey]: 'value' });
const primitiveOut = summarizeParams('x'.repeat(300));
expect(objectOut).toHaveLength(200);
expect(objectOut.endsWith('...')).toBe(true);
expect(primitiveOut).toHaveLength(200);
expect(primitiveOut.endsWith('...')).toBe(true);
});
});
describe('redactLogMessage', () => {
it('redacts explicit authentication assignments without changing prose', () => {
const out = redactLogMessage(
'password=secret Authorization: Bearer bearer-secret cookie="cookie secret" password change required',
);
expect(out).toBe('password=*** Authorization: *** cookie=*** password change required');
});
it('redacts quoted object keys, Basic auth, and complete Cookie values', () => {
const out = redactLogMessage(
`json={"authorization":"Basic json-auth"} object={ 'api-key': 'api-secret' } Authorization: Basic header-auth Cookie=session=cookie-secret; Path=/; HttpOnly`,
);
const expires = redactLogMessage(
'Cookie=session=expires-secret; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Path=/; HttpOnly safe=visible',
);
expect(out).toContain('json={"authorization":***}');
expect(out).toContain("object={ 'api-key': *** }");
expect(out).toContain('Authorization: *** Cookie=***');
expect(expires).toBe('Cookie=*** safe=visible');
expect(out).not.toMatch(/json-auth|api-secret|header-auth|cookie-secret|Path=|HttpOnly/);
expect(expires).not.toMatch(/expires-secret|Expires|21 Oct|Path=|HttpOnly/);
});
it('redacts complete multi-parameter Authorization values', () => {
const out = redactLogMessage(
'Authorization: Digest username="alice", realm="qq", response="digest-secret"\n' +
'authorization=AWS4-HMAC-SHA256 Credential=AKID/20260730/cn/service/aws4_request, ' +
'SignedHeaders=host;x-date, Signature=aws-secret\n' +
'Authorization: Bearer bearer-secret Cookie=session=cookie-secret; Path=/',
);
expect(out).toBe('Authorization: ***\nauthorization=***\nAuthorization: *** Cookie=***');
expect(out).not.toMatch(
/alice|realm|digest-secret|AKID|SignedHeaders|aws-secret|bearer-secret|cookie-secret|Path=/,
);
});
it('redacts camelCase authentication keys without matching larger words', () => {
const out = redactLogMessage(
`authToken=auth-secret idToken=id-secret clientSecret=client-secret {"userPassword":"password-secret"}`,
);
expect(out).toBe(`authToken=*** idToken=*** clientSecret=*** {"userPassword":***}`);
expect(out).not.toMatch(/auth-secret|id-secret|client-secret|password-secret/);
expect(redactLogMessage('retoken=value cookietown=value myauthorization=value')).toBe(
'retoken=value cookietown=value myauthorization=value',
);
});
it('redacts leading separator and numeric authentication keys without matching larger words', () => {
expect(
redactLogMessage(
'_token=underscore-secret --password=cli-secret 2faToken=two-factor-secret retoken=visible',
),
).toBe('_token=*** --password=*** 2faToken=*** retoken=visible');
});
it('redacts nested assignments inside non-sensitive wrapper values', () => {
expect(redactLogMessage('params=authToken=auth-secret params=cookie="cookie-secret"')).toBe(
'params=authToken=*** params=cookie=***',
);
});
it('redacts the entire Authorization value when it starts with a marker', () => {
expect(redactLogMessage('Authorization: *** secret-value')).toBe('Authorization: ***');
});
it('redacts property paths and URL query authentication values', () => {
const out = redactLogMessage(
'headers.authorization=Basic dXNlcjpwYXNz ' +
'request.token=plain-token ' +
'GET /callback?access_token=query-secret&x=1 ' +
'url=https://host/?api_key=url-secret',
);
expect(out).toContain('headers.authorization=***');
expect(out).toContain('request.token=***');
expect(out).toContain('/callback?access_token=***&x=1');
expect(out).toContain('https://host/?api_key=***');
expect(out).not.toMatch(/dXNlcjpwYXNz|plain-token|query-secret|url-secret/);
});
it('redacts prefixed authentication keys without matching larger words', () => {
const redacted = redactLogMessage(
'x-api-key=api-secret user_password=password-secret ' +
'headers.authorization=Basic auth-secret ' +
'x-cookie="session=cookie-secret"; Path=/; HttpOnly',
);
expect(redacted).toContain('x-api-key=***');
expect(redacted).toContain('user_password=***');
expect(redacted).toContain('headers.authorization=***');
expect(redacted).toContain('x-cookie=***');
expect(redacted).not.toMatch(
/api-secret|password-secret|auth-secret|cookie-secret|Path=|HttpOnly/,
);
expect(redactLogMessage('retoken=value cookietown=value myauthorization=value')).toBe(
'retoken=value cookietown=value myauthorization=value',
);
});
});
describe('renderParamsVerbose', () => {
it('keeps authentication fields unredacted for complete TRACE diagnostics', () => {
const out = renderParamsVerbose({
token: 'token-value',
Cookie: 'cookie-value',
password: 'password-value',
apiKey: 'key-value',
});
expect(out).toContain('token:"token-value"');
expect(out).toContain('Cookie:"cookie-value"');
expect(out).toContain('password:"password-value"');
expect(out).toContain('apiKey:"key-value"');
expect(out).not.toContain('"***"');
});
it('does not truncate TRACE values or their enclosing object', () => {
const unicodeValue = '雪'.repeat(800);
const out = renderParamsVerbose({ blob: unicodeValue, tail: 'complete' });
expect(out).toContain(`blob:${JSON.stringify(unicodeValue)}`);
expect(out).toContain('tail:"complete"');
expect(out).not.toMatch(/…<\d+B>|\.\.\./);
});
it('guards against circular references', () => {
const o: Record<string, unknown> = { a: 1 };
o.self = o;
expect(renderParamsVerbose(o)).toContain('"[circular]"');
});
it('renders shared non-cyclic references in every branch', () => {
const shared = { value: 1 };
expect(renderParamsVerbose({ left: shared, right: shared })).toBe(
'{left:{value:1},right:{value:1}}',
);
});
it('renders nested structure with primitives intact', () => {
expect(renderParamsVerbose({ a: 1, b: [true, 'x'] })).toBe('{a:1,b:[true,"x"]}');
});
});
@@ -0,0 +1,99 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
const { fileWrite } = vi.hoisted(() => ({ fileWrite: vi.fn() }));
vi.mock('../src/log-file-transport', () => ({
getFileTransport: () => ({ write: fileWrite, close: async () => {} }),
}));
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
vi.resetModules();
fileWrite.mockClear();
});
async function setup() {
vi.stubEnv('SNOWLUMA_LOG_LEVEL', 'trace');
vi.stubEnv('SNOWLUMA_LOG_FILE_LEVEL', 'error');
vi.stubEnv('SNOWLUMA_TRACE_BUFFER', '100');
vi.spyOn(process.stdout, 'write').mockReturnValue(true);
vi.spyOn(process.stderr, 'write').mockReturnValue(true);
const logger = await import('../src/logger');
return { ...logger, log: logger.createLogger('Snapshot') };
}
describe('retained log snapshots', () => {
it('returns empty snapshots before any record has been emitted', async () => {
const logger = await setup();
expect(logger.getRecentLogs()).toEqual([]);
expect(logger.getLogSnapshot()).toEqual([]);
});
it('merges wrapped rings in emission order and returns only the requested tail', async () => {
const logger = await setup();
const emitted: import('../src/logger').LogEntry[] = [];
const unsubscribe = logger.subscribeLogs((entry) => emitted.push(entry));
for (let i = 0; i < 1100; i += 1) {
logger.log.info('normal %d', i);
logger.log.trace('trace %d', i);
}
unsubscribe();
const normal = emitted.filter((entry) => entry.level !== 'trace').slice(-1000);
const trace = emitted.filter((entry) => entry.level === 'trace').slice(-100);
const expected = [...normal, ...trace].sort((a, b) => a.id - b.id);
expect(logger.getLogSnapshot()).toEqual(expected);
for (const limit of [1, 2, 30, 300, 1000, 1100, 2000, 0, -1, 2.9, NaN, Infinity]) {
expect(logger.getRecentLogs(limit)).toEqual(
expected.slice(-Math.max(1, Math.trunc(limit))),
);
}
expect(logger.getRecentLogs()).toEqual(expected.slice(-300));
const snapshot = logger.getRecentLogs(5);
snapshot.pop();
expect(logger.getRecentLogs(5)).toEqual(expected.slice(-5));
});
it.each(['info', 'trace'] as const)(
'reads a tail when only the %s ring has records',
async (level) => {
const logger = await setup();
logger.log[level]('first');
logger.log[level]('second');
expect(logger.getRecentLogs(1).map((entry) => entry.message)).toEqual(['second']);
expect(logger.getLogSnapshot().map((entry) => entry.message)).toEqual([
'first',
'second',
]);
},
);
it('preserves ID gaps and file routing without publishing file-only entries', async () => {
const logger = await setup();
vi.stubEnv('SNOWLUMA_LOG_FILE_LEVEL', 'debug');
vi.resetModules();
const fresh = await import('../src/logger');
fresh.setLogLevel('info');
const entries: import('../src/logger').LogEntry[] = [];
const unsubscribe = fresh.subscribeLogs((entry) => entries.push(entry));
const log = fresh.createLogger('FileOnly').child({ uin: 10001 });
log.info('before');
fresh.runWithRequestId(42, () => log.debug('password=secret file-only\x07'));
log.info('after');
unsubscribe();
expect(entries.map((entry) => entry.message)).toEqual(['before', 'after']);
expect(entries[1]!.id).toBe(entries[0]!.id + 2);
expect(fresh.getLogSnapshot()).toEqual(entries);
expect(fileWrite).toHaveBeenCalledWith(
expect.stringMatching(/\[req#42\] password=\*\*\* file-only/),
10001,
);
expect(process.stdout.write).toHaveBeenCalledTimes(2);
expect(logger.getLogSnapshot()).toEqual([]);
});
});
+338
View File
@@ -0,0 +1,338 @@
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const { externalFileLevel, externalLogLevel, fileWriteSpy } = vi.hoisted(() => {
const savedFileLevel = process.env.SNOWLUMA_LOG_FILE_LEVEL;
const savedLogLevel = process.env.SNOWLUMA_LOG_LEVEL;
delete process.env.SNOWLUMA_LOG_FILE_LEVEL;
delete process.env.SNOWLUMA_LOG_LEVEL;
return {
externalFileLevel: savedFileLevel,
externalLogLevel: savedLogLevel,
fileWriteSpy: vi.fn(),
};
});
// Capture file transport writes via a spy so tests can assert on what reaches
// the file.
vi.mock('../src/log-file-transport', () => ({
getFileTransport: () => ({ write: fileWriteSpy, close: async () => {} }),
}));
import { createLogger, subscribeLogs, type LogEntry } from '../src/logger';
// Regression for issue #162: a hook-reported garbage UIN (13-digit, timestamp
// shaped) produced a `[…]` tag wider than the fixed UIN slot, and the colored
// render path did `' '.repeat(slot - tagLen)` → RangeError: Invalid count value
// → uncaughtException. The padding must clamp at zero.
describe('logger UIN slot padding', () => {
let prevTTY: boolean | undefined;
let prevNoColor: string | undefined;
beforeEach(() => {
prevTTY = process.stdout.isTTY;
prevNoColor = process.env.NO_COLOR;
// Force the colored render path (the only one that used .repeat()).
(process.stdout as unknown as { isTTY: boolean }).isTTY = true;
delete process.env.NO_COLOR;
vi.spyOn(process.stdout, 'write').mockReturnValue(true);
vi.spyOn(process.stderr, 'write').mockReturnValue(true);
});
afterEach(() => {
(process.stdout as unknown as { isTTY: boolean | undefined }).isTTY = prevTTY;
if (prevNoColor === undefined) {
delete process.env.NO_COLOR;
} else {
process.env.NO_COLOR = prevNoColor;
}
vi.restoreAllMocks();
});
it('does not throw when the UIN tag exceeds the slot width', () => {
const log = createLogger('Test').child({ uin: '1701414379536' }); // 13-digit → [..] = 15 chars
expect(() => log.info('phantom account line')).not.toThrow();
expect(() => log.error('phantom error line')).not.toThrow();
});
it('still renders a normal-width UIN and a no-UIN logger', () => {
expect(() => createLogger('Test').child({ uin: '10001' }).info('ok')).not.toThrow();
expect(() => createLogger('Test').info('no uin')).not.toThrow();
});
it('does not throw when the console stream cannot accept the line', () => {
vi.mocked(process.stderr.write).mockImplementation(() => {
throw Object.assign(new Error('ENOSPC: no space left on device'), { code: 'ENOSPC' });
});
expect(() => createLogger('Test').error('disk is full')).not.toThrow();
});
it('keeps structured subscriber lines plain while terminal output stays colored', () => {
const captured: LogEntry[] = [];
const unsubscribe = subscribeLogs((entry) => captured.push(entry));
createLogger('WebUI.Export').info('download me');
unsubscribe();
expect(captured).toHaveLength(1);
expect(captured[0]!.line).toMatch(/INFO\s+\[WebUI\.Export\] download me$/);
expect(process.stdout.write).toHaveBeenCalledWith(expect.stringContaining('\x1b['));
});
});
// ─── Independent file-level filtering ─────────────────────────────────────
const SAVED_FILE_LEVEL = externalFileLevel;
async function loadLoggerForFileLevel(level?: string) {
if (level === undefined) {
delete process.env.SNOWLUMA_LOG_FILE_LEVEL;
} else {
process.env.SNOWLUMA_LOG_FILE_LEVEL = level;
}
vi.resetModules();
fileWriteSpy.mockClear();
return import('../src/logger');
}
afterEach(() => {
if (SAVED_FILE_LEVEL === undefined) {
delete process.env.SNOWLUMA_LOG_FILE_LEVEL;
} else {
process.env.SNOWLUMA_LOG_FILE_LEVEL = SAVED_FILE_LEVEL;
}
vi.resetModules();
fileWriteSpy.mockClear();
});
afterAll(() => {
if (SAVED_FILE_LEVEL === undefined) {
delete process.env.SNOWLUMA_LOG_FILE_LEVEL;
} else {
process.env.SNOWLUMA_LOG_FILE_LEVEL = SAVED_FILE_LEVEL;
}
if (externalLogLevel === undefined) {
delete process.env.SNOWLUMA_LOG_LEVEL;
} else {
process.env.SNOWLUMA_LOG_LEVEL = externalLogLevel;
}
});
describe('file output gating', () => {
const cases = [
{ level: undefined, expected: ['debug', 'info', 'success', 'warn', 'error'] },
{ level: 'debug', expected: ['debug', 'info', 'success', 'warn', 'error'] },
{ level: 'info', expected: ['info', 'success', 'warn', 'error'] },
{ level: 'success', expected: ['success', 'warn', 'error'] },
{ level: 'warn', expected: ['warn', 'error'] },
{ level: 'error', expected: ['error'] },
] as const;
for (const testCase of cases) {
it(`writes exactly the enabled levels for ${testCase.level ?? 'the default'}`, async () => {
const fresh = await loadLoggerForFileLevel(testCase.level);
const log = fresh.createLogger('Test');
fresh.setLogLevel('trace');
log.trace('trace');
log.debug('debug');
log.info('info');
log.success('success');
log.warn('warn');
log.error('error');
const messages = fileWriteSpy.mock.calls.map(([line]) =>
String(line).split('] ').at(-1),
);
expect(messages).toEqual(testCase.expected);
});
}
it('accepts surrounding whitespace and mixed case', async () => {
const fresh = await loadLoggerForFileLevel(' INFO ');
const log = fresh.createLogger('Test');
log.debug('debug');
log.info('info');
expect(fileWriteSpy).toHaveBeenCalledTimes(1);
expect(fileWriteSpy).toHaveBeenCalledWith(expect.stringContaining('info'), undefined);
});
it.each(['trace', 'verbose', 'erorr'])(
'rejects unsupported file level %s at startup',
async (level) => {
await expect(loadLoggerForFileLevel(level)).rejects.toThrow(
'SNOWLUMA_LOG_FILE_LEVEL must be one of: debug, info, success, warn, error',
);
expect(fileWriteSpy).not.toHaveBeenCalled();
},
);
it('does not let the runtime console level change file output', async () => {
const fresh = await loadLoggerForFileLevel('info');
const log = fresh.createLogger('Test');
fresh.setLogLevel('error');
log.info('persisted-info');
fresh.setLogLevel('trace');
log.debug('filtered-debug');
expect(fileWriteSpy).toHaveBeenCalledTimes(1);
expect(fileWriteSpy).toHaveBeenCalledWith(
expect.stringContaining('persisted-info'),
undefined,
);
});
it('skips rendering when both console and file filters reject a record', async () => {
const fresh = await loadLoggerForFileLevel('error');
fresh.setLogLevel('error');
const rendered = vi.fn(() => 'expensive');
const value = { [Symbol.toPrimitive]: rendered };
fresh.createLogger('Test').debug('value=%s', value);
expect(rendered).not.toHaveBeenCalled();
expect(fileWriteSpy).not.toHaveBeenCalled();
});
it('keeps trace out of files in an explicit file-threshold check', async () => {
const fresh = await loadLoggerForFileLevel('debug');
fresh.setLogLevel('trace');
fresh.createLogger('Test').trace('full chain');
expect(fileWriteSpy).not.toHaveBeenCalled();
});
it('redacts authentication assignments from ordinary logs but not TRACE', async () => {
const fresh = await loadLoggerForFileLevel('debug');
fresh.setLogLevel('trace');
const entries: LogEntry[] = [];
const unsubscribe = fresh.subscribeLogs((entry) => entries.push(entry));
const log = fresh.createLogger('Test');
log.info(
'password=%s Authorization: Bearer %s Cookie=%s token=%s safe=%s',
'ordinary-password',
'ordinary-authorization',
'ordinary-cookie',
'ordinary-token',
'visible',
);
log.trace(
'password=%s Authorization: Bearer %s Cookie=%s token=%s',
'trace-password',
'trace-authorization',
'trace-cookie',
'trace-token',
);
log.info(
`json={"authorization":"Basic json-auth"} object={ 'api-key': 'api-secret' } Authorization: Basic header-auth Cookie=session=cookie-secret; Path=/; HttpOnly`,
);
log.info(
'headers.authorization=Basic dotted-auth request.token=dotted-token ' +
'GET /callback?access_token=query-secret&x=1 ' +
'url=https://host/?api_key=url-secret',
);
log.info(
'Authorization: Digest username="alice", response="digest-secret"\n' +
'authorization=AWS4-HMAC-SHA256 Credential=AKID, Signature=aws-secret',
);
log.info(
'authToken=camel-auth idToken=camel-id clientSecret=camel-client ' +
`json={"userPassword":"camel-password"}`,
);
log.info('_token=underscore-secret --password=cli-secret 2faToken=two-factor-secret');
log.info('Authorization: *** marker-secret');
log.trace(
`json={"authorization":"Basic trace-json-auth"} Authorization: Basic trace-header-auth Cookie=session=trace-cookie; Path=/`,
);
unsubscribe();
const ordinaryEntries = entries.filter((entry) => entry.level === 'info');
const ordinary = ordinaryEntries.map((entry) => entry.message).join('\n');
expect(ordinary).toContain('password=***');
expect(ordinary).toContain('Authorization: ***');
expect(ordinary).toContain('Cookie=***');
expect(ordinary).toContain('token=***');
expect(ordinary).toContain('headers.authorization=***');
expect(ordinary).toContain('request.token=***');
expect(ordinary).toContain('/callback?access_token=***&x=1');
expect(ordinary).toContain('https://host/?api_key=***');
expect(ordinary).toContain('Authorization: ***\nauthorization=***');
expect(ordinary).toContain('authToken=***');
expect(ordinary).toContain('idToken=***');
expect(ordinary).toContain('clientSecret=***');
expect(ordinary).toContain(`json={"userPassword":***}`);
expect(ordinary).toContain('_token=***');
expect(ordinary).toContain('--password=***');
expect(ordinary).toContain('2faToken=***');
expect(ordinary).toContain('Authorization: ***');
expect(ordinary).toContain('safe=visible');
expect(ordinary).not.toMatch(
/ordinary-password|ordinary-authorization|ordinary-cookie|ordinary-token|json-auth|api-secret|header-auth|cookie-secret|dotted-auth|dotted-token|query-secret|url-secret|digest-secret|AKID|aws-secret|camel-auth|camel-id|camel-client|camel-password|underscore-secret|cli-secret|two-factor-secret|marker-secret/,
);
const trace = entries
.filter((entry) => entry.level === 'trace')
.map((entry) => entry.message)
.join('\n');
expect(trace).toContain('trace-password');
expect(trace).toContain('trace-authorization');
expect(trace).toContain('trace-cookie');
expect(trace).toContain('trace-token');
expect(trace).toContain('trace-json-auth');
expect(trace).toContain('trace-header-auth');
const persisted = fileWriteSpy.mock.calls.map(([line]) => String(line)).join('\n');
expect(persisted).not.toMatch(
/ordinary-password|ordinary-authorization|ordinary-cookie|ordinary-token|json-auth|api-secret|header-auth|cookie-secret|dotted-auth|dotted-token|query-secret|url-secret|digest-secret|AKID|aws-secret|trace-password|trace-json-auth/,
);
});
it('returns the complete retained normal and TRACE snapshot in record order', async () => {
const fresh = await loadLoggerForFileLevel('debug');
const log = fresh.createLogger('Export');
fresh.setLogLevel('trace');
log.info('normal-before');
log.trace('trace-hex=001122aabbcc');
log.warn('normal-after');
expect(fresh.getRecentLogs(1).map((entry) => entry.message)).toEqual(['normal-after']);
expect(fresh.getLogSnapshot().map((entry) => entry.message)).toEqual([
'normal-before',
'trace-hex=001122aabbcc',
'normal-after',
]);
});
it('prints bootstrap credentials only to the current process terminal', async () => {
const fresh = await loadLoggerForFileLevel('error');
fresh.setLogLevel('error');
const entries: LogEntry[] = [];
const unsubscribe = fresh.subscribeLogs((entry) => entries.push(entry));
const snapshotBefore = fresh.getLogSnapshot();
const stdout = vi.spyOn(process.stdout, 'write').mockReturnValue(true);
try {
fresh.logInitialWebuiCredentials('secret');
expect(stdout).toHaveBeenCalledWith(
expect.stringContaining('initial credentials: user=admin password=secret'),
);
expect(entries).toEqual([]);
expect(fresh.getLogSnapshot()).toEqual(snapshotBefore);
expect(fileWriteSpy).not.toHaveBeenCalled();
} finally {
unsubscribe();
stdout.mockRestore();
}
});
});
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { currentRequestId, nextRequestId, runWithRequestId } from '../src/request-context';
describe('request-context', () => {
it('nextRequestId increments monotonically and stays a positive integer', () => {
const a = nextRequestId();
const b = nextRequestId();
expect(a).toBeGreaterThan(0);
expect(b).toBe(a + 1);
});
it('runWithRequestId binds the id across sync + async, with nested scopes restoring', async () => {
expect(currentRequestId()).toBeUndefined();
runWithRequestId(42, () => {
expect(currentRequestId()).toBe(42);
runWithRequestId(99, () => {
expect(currentRequestId()).toBe(99);
});
expect(currentRequestId()).toBe(42); // outer scope restored after the nested one
});
expect(currentRequestId()).toBeUndefined(); // cleared outside any scope
await runWithRequestId(7, async () => {
await Promise.resolve();
expect(currentRequestId()).toBe(7); // survives awaits in the same async chain
});
});
it('returns the callback result', () => {
expect(runWithRequestId(1, () => 'value')).toBe('value');
});
});
+229
View File
@@ -0,0 +1,229 @@
import fs from 'node:fs';
import path from 'node:path';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { normalizeRuntimeConfig, resolveRuntimeEnvOverrides } from '../src/runtime';
describe('normalizeRuntimeConfig', () => {
it('applies all defaults for an empty object', () => {
expect(normalizeRuntimeConfig({})).toEqual({
webuiPort: 5099,
hookAutoLoad: false,
webuiHost: '127.0.0.1',
webuiTls: { enabled: false },
trustProxy: '',
logMaxTotalMb: 1024,
logRetainDays: 7,
logPerUin: false,
});
});
it('passes through valid values', () => {
expect(
normalizeRuntimeConfig({
webuiPort: 8080,
hookAutoLoad: true,
webuiHost: '127.0.0.1',
webuiTls: { enabled: true },
trustProxy: '1',
logMaxTotalMb: 2048,
logRetainDays: 0,
logPerUin: true,
}),
).toEqual({
webuiPort: 8080,
hookAutoLoad: true,
webuiHost: '127.0.0.1',
webuiTls: { enabled: true },
trustProxy: '1',
logMaxTotalMb: 2048,
logRetainDays: 0,
logPerUin: true,
});
});
it('falls back on invalid types', () => {
const out = normalizeRuntimeConfig({
webuiPort: 0, // out of range → default
webuiHost: 123, // non-string → default
webuiTls: 'yes', // non-object → default
trustProxy: 5, // non-string → default
});
expect(out.webuiPort).toBe(5099);
expect(out.webuiHost).toBe('127.0.0.1');
expect(out.webuiTls).toEqual({ enabled: false });
expect(out.trustProxy).toBe('');
});
it('fails fast on invalid persisted log settings', () => {
expect(() => normalizeRuntimeConfig({ logMaxTotalMb: 0 })).toThrow(/logMaxTotalMb/);
expect(() => normalizeRuntimeConfig({ logRetainDays: -1 })).toThrow(/logRetainDays/);
expect(() => normalizeRuntimeConfig({ logPerUin: 'maybe' })).toThrow(/logPerUin/);
expect(() => normalizeRuntimeConfig({ logPerUin: '' })).toThrow(/logPerUin/);
expect(() => normalizeRuntimeConfig({ logMaxTotalMb: Number.MAX_SAFE_INTEGER })).toThrow(
/logMaxTotalMb/,
);
expect(() => normalizeRuntimeConfig({ logRetainDays: Number.MAX_SAFE_INTEGER })).toThrow(
/logRetainDays/,
);
});
it('coerces webuiTls.enabled loosely and trims a blank host to default', () => {
expect(normalizeRuntimeConfig({ webuiTls: { enabled: 1 } }).webuiTls).toEqual({
enabled: true,
});
expect(normalizeRuntimeConfig({ webuiHost: ' ' }).webuiHost).toBe('127.0.0.1');
});
it('rejects a non-object input back to full defaults', () => {
expect(normalizeRuntimeConfig(null).webuiPort).toBe(5099);
expect(normalizeRuntimeConfig('nope').webuiHost).toBe('127.0.0.1');
});
});
describe('resolveRuntimeEnvOverrides', () => {
it('returns empty when no SNOWLUMA_* vars are set', () => {
expect(resolveRuntimeEnvOverrides({})).toEqual({});
});
it('parses port / host / trustProxy from env', () => {
expect(
resolveRuntimeEnvOverrides({
SNOWLUMA_WEBUI_PORT: '6700',
SNOWLUMA_WEBUI_HOST: '127.0.0.1',
SNOWLUMA_WEBUI_TRUST_PROXY: '1',
}),
).toEqual({ webuiPort: 6700, webuiHost: '127.0.0.1', trustProxy: '1' });
});
it('parses log storage overrides including retainDays=0', () => {
expect(
resolveRuntimeEnvOverrides({
SNOWLUMA_LOG_MAX_TOTAL_MB: '4096',
SNOWLUMA_LOG_RETAIN_DAYS: '0',
SNOWLUMA_LOG_PER_UIN: '1',
}),
).toEqual({
logMaxTotalMb: 4096,
logRetainDays: 0,
logPerUin: true,
});
});
it('fails fast on invalid log storage overrides', () => {
expect(() =>
resolveRuntimeEnvOverrides({
SNOWLUMA_LOG_MAX_TOTAL_MB: '0',
}),
).toThrow(/SNOWLUMA_LOG_MAX_TOTAL_MB/);
expect(() =>
resolveRuntimeEnvOverrides({
SNOWLUMA_LOG_RETAIN_DAYS: '-1',
}),
).toThrow(/SNOWLUMA_LOG_RETAIN_DAYS/);
expect(() =>
resolveRuntimeEnvOverrides({
SNOWLUMA_LOG_PER_UIN: 'maybe',
}),
).toThrow(/SNOWLUMA_LOG_PER_UIN/);
});
it('fails fast when converted log storage overrides would be unsafe', () => {
expect(() =>
resolveRuntimeEnvOverrides({
SNOWLUMA_LOG_MAX_TOTAL_MB: String(Number.MAX_SAFE_INTEGER),
}),
).toThrow(/SNOWLUMA_LOG_MAX_TOTAL_MB/);
expect(() =>
resolveRuntimeEnvOverrides({
SNOWLUMA_LOG_RETAIN_DAYS: String(Number.MAX_SAFE_INTEGER),
}),
).toThrow(/SNOWLUMA_LOG_RETAIN_DAYS/);
});
it('parses SNOWLUMA_WEBUI_TLS over the saved switch', () => {
expect(resolveRuntimeEnvOverrides({ SNOWLUMA_WEBUI_TLS: '1' })).toEqual({
webuiTls: { enabled: true },
});
expect(resolveRuntimeEnvOverrides({ SNOWLUMA_WEBUI_TLS: '0' })).toEqual({
webuiTls: { enabled: false },
});
expect(() => resolveRuntimeEnvOverrides({ SNOWLUMA_WEBUI_TLS: 'maybe' })).toThrow(
/SNOWLUMA_WEBUI_TLS/,
);
});
it('ignores an out-of-range / non-numeric port env', () => {
expect(resolveRuntimeEnvOverrides({ SNOWLUMA_WEBUI_PORT: '0' })).toEqual({});
expect(resolveRuntimeEnvOverrides({ SNOWLUMA_WEBUI_PORT: 'abc' })).toEqual({});
});
it('treats trustProxy="0"/"off" as a real override (not absent)', () => {
expect(resolveRuntimeEnvOverrides({ SNOWLUMA_WEBUI_TRUST_PROXY: '0' })).toEqual({
trustProxy: '0',
});
});
});
describe('updateRuntimeConfig (fs)', () => {
let prevCwd: string;
let dir: string;
let prevPortEnv: string | undefined;
beforeEach(async () => {
const fs = await import('fs');
const os = await import('os');
const path = await import('path');
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sl-rt-'));
prevCwd = process.cwd();
process.chdir(dir);
prevPortEnv = process.env.SNOWLUMA_WEBUI_PORT;
delete process.env.SNOWLUMA_WEBUI_PORT;
});
afterEach(async () => {
process.chdir(prevCwd);
if (prevPortEnv === undefined) {
delete process.env.SNOWLUMA_WEBUI_PORT;
} else {
process.env.SNOWLUMA_WEBUI_PORT = prevPortEnv;
}
fs.rmSync(dir, { recursive: true, force: true });
vi.restoreAllMocks();
});
it('merges a patch over on-disk values and persists normalized', async () => {
const { updateRuntimeConfig } = await import('../src/runtime');
const fs = await import('fs');
const path = await import('path');
const out = updateRuntimeConfig({ webuiHost: '127.0.0.1', webuiTls: { enabled: true } });
expect(out.webuiHost).toBe('127.0.0.1');
expect(out.webuiTls).toEqual({ enabled: true });
const onDisk = JSON.parse(fs.readFileSync(path.join('config', 'runtime.json'), 'utf8'));
expect(onDisk.webuiHost).toBe('127.0.0.1');
expect(onDisk.webuiTls).toEqual({ enabled: true });
});
it('does NOT bake an env-overridden port into the persisted file', async () => {
const { updateRuntimeConfig } = await import('../src/runtime');
const fs = await import('fs');
const path = await import('path');
process.env.SNOWLUMA_WEBUI_PORT = '9999';
updateRuntimeConfig({ webuiHost: '127.0.0.1' });
const onDisk = JSON.parse(fs.readFileSync(path.join('config', 'runtime.json'), 'utf8'));
expect(onDisk.webuiPort).toBe(5099); // default on-disk, NOT the env 9999
});
it('keeps the previous runtime file intact when atomic replacement fails', async () => {
const { updateRuntimeConfig } = await import('../src/runtime');
updateRuntimeConfig({ logRetainDays: 7 });
const runtimePath = path.join('config', 'runtime.json');
const before = fs.readFileSync(runtimePath, 'utf8');
vi.spyOn(fs, 'renameSync').mockImplementationOnce(() => {
throw Object.assign(new Error('disk replacement failed'), { code: 'EIO' });
});
expect(() => updateRuntimeConfig({ logRetainDays: 0 })).toThrow(/disk replacement failed/);
expect(fs.readFileSync(runtimePath, 'utf8')).toBe(before);
expect(fs.readdirSync('config').filter((name) => name.includes('.tmp-'))).toEqual([]);
});
});
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest';
import { compareVersions, parseVersion } from '../src/semver';
describe('parseVersion', () => {
it.each([
'0.0.0',
'1.2.3',
'1.2.3-rc.1',
'1.2.3-alpha-beta',
'1.2.3+001.build',
'1.2.3-rc.1+build.2',
])('accepts canonical SemVer and preserves its full value: %s', (version) => {
expect(parseVersion(version)?.raw).toBe(version);
});
it.each([
undefined,
null,
123,
{},
'',
'1.2',
'1.2.3.4',
'1.2.x',
'01.2.3',
'1.02.3',
'1.2.03',
'1.2.3-01',
'1.2.3-rc.01',
'1.2.3-rc..1',
'1.2.3-rc_1',
'1.2.3+build..1',
'1.2.3+build_1',
'v1.2.3',
'V1.2.3',
'=1.2.3',
' 1.2.3',
'1.2.3\n',
])('rejects noncanonical input without coercion: %j', (version) => {
expect(parseVersion(version)).toBeNull();
});
});
describe('compareVersions', () => {
it.each([
['1.2.3', '1.2.3', 0],
['1.2.4', '1.2.3', 1],
['1.2.3', '1.2.4', -1],
['1.10.0', '1.9.0', 1],
['2.0.0', '1.9.9', 1],
['3.0.0', '1.0.0', 1],
['1.0.0', '3.0.0', -1],
['1.2.3', '1.2.3-rc.1', 1],
['1.2.3-rc.1', '1.2.3', -1],
['1.2.3-rc.1', '1.2.3-rc.1', 0],
['1.2.3-rc.1', '1.2.3-rc.2', -1],
['1.2.3-rc.2', '1.2.3-rc.1', 1],
['1.2.3-rc.10', '1.2.3-rc.2', 1],
['1.2.3-rc.1+build.2', '1.2.3-rc.1+build.1', 0],
['1.2.3-alpha-beta', '1.2.3-alpha', 1],
['2.0.0-rc.1', '1.9.9', 1],
] as const)('compareVersions(%j, %j) === %s', (a, b, expected) => {
expect(compareVersions(a, b)).toBe(expected);
});
it.each(['1.2', '1.2.3.9', '1.2.x', '', 'v1.2.3', 'V1.2.3', '01.2.3', '1.2.3-rc.01'])(
'rejects invalid versions instead of guessing: %j',
(version) => {
expect(() => compareVersions(version, '1.2.3')).toThrow();
expect(() => compareVersions('1.2.3', version)).toThrow();
},
);
});
@@ -0,0 +1,327 @@
import { afterEach, describe, it, expect, vi } from 'vitest';
import { createSingleFlightCache } from '../src/single-flight-cache';
/** A promise whose resolve/reject is exposed, for driving single-flight races. */
function deferred<T>() {
let resolve!: (v: T) => void;
let reject!: (e: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
describe('SingleFlightCache', () => {
afterEach(() => {
vi.useRealTimers();
});
it('disposes retained values and rejects new reads without restarting loads or timers', async () => {
vi.useFakeTimers();
const load = vi.fn(async (key: string) => ({ key }));
const cache = createSingleFlightCache({ ttlMs: 300_000, load });
await cache.get('first');
await cache.get('second');
expect(vi.getTimerCount()).toBe(1);
cache.dispose();
cache.dispose();
cache.clear();
expect(cache.size).toBe(0);
expect(vi.getTimerCount()).toBe(0);
await expect(cache.get('first')).rejects.toThrow('disposed');
await expect(cache.get('new', { force: true })).rejects.toThrow('disposed');
expect(load).toHaveBeenCalledTimes(2);
});
it.each(['resolve', 'reject'] as const)(
'preserves a pending %s after disposal without retaining the result',
async (outcome) => {
vi.useFakeTimers();
const pending = deferred<string>();
const cache = createSingleFlightCache({ ttlMs: 300_000, load: () => pending.promise });
const first = cache.get('key');
expect(cache.get('key')).toBe(first);
cache.dispose();
if (outcome === 'resolve') {
pending.resolve('late value');
await expect(first).resolves.toBe('late value');
} else {
pending.reject(new Error('late failure'));
await expect(first).rejects.toThrow('late failure');
}
expect(cache.size).toBe(0);
expect(vi.getTimerCount()).toBe(0);
},
);
it('releases expired values while idle without expiring newer values early', async () => {
vi.useFakeTimers();
const load = vi.fn(async (key: string) => ({ key }));
const cache = createSingleFlightCache({ ttlMs: 1000, load });
await cache.get('old');
await vi.advanceTimersByTimeAsync(500);
const newer = await cache.get('new');
expect(cache.size).toBe(2);
await vi.advanceTimersByTimeAsync(500);
expect(cache.size).toBe(1);
expect(await cache.get('new')).toBe(newer);
expect(load).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(500);
expect(cache.size).toBe(0);
expect(vi.getTimerCount()).toBe(0);
});
it('keeps a refreshed entry until its new deadline and expires other keys', async () => {
vi.useFakeTimers();
const cache = createSingleFlightCache({ ttlMs: 1000, load: async (key: string) => key });
await cache.get('refreshed');
await cache.get('old');
await vi.advanceTimersByTimeAsync(500);
await cache.get('refreshed', { force: true });
await vi.advanceTimersByTimeAsync(500);
expect(cache.size).toBe(1);
await vi.advanceTimersByTimeAsync(500);
expect(cache.size).toBe(0);
});
it.each(['invalidate', 'clear'] as const)(
'%s cancels idle cleanup when the last value is removed',
async (operation) => {
vi.useFakeTimers();
const cache = createSingleFlightCache({ ttlMs: 1000, load: async () => 'value' });
await cache.get('key');
expect(vi.getTimerCount()).toBe(1);
cache[operation]('key');
expect(cache.size).toBe(0);
expect(vi.getTimerCount()).toBe(0);
await cache.get('key');
await vi.advanceTimersByTimeAsync(1000);
expect(cache.size).toBe(0);
},
);
it('does not let expiration detach a pending refresh', async () => {
vi.useFakeTimers();
const pending = deferred<string>();
const load = vi.fn().mockResolvedValueOnce('old').mockReturnValue(pending.promise);
const cache = createSingleFlightCache<string, string>({ ttlMs: 1000, load });
await cache.get('key');
const refresh = cache.get('key', { force: true });
await vi.advanceTimersByTimeAsync(1000);
expect(cache.size).toBe(0);
expect(cache.get('key')).toBe(refresh);
pending.resolve('new');
await expect(refresh).resolves.toBe('new');
expect(cache.size).toBe(1);
expect(load).toHaveBeenCalledTimes(2);
await vi.advanceTimersByTimeAsync(1000);
expect(cache.size).toBe(0);
});
it('does not retain values when caching is disabled by a zero TTL', async () => {
vi.useFakeTimers();
const cache = createSingleFlightCache({ ttlMs: 0, load: async () => 'value' });
await expect(cache.get('key')).resolves.toBe('value');
expect(cache.size).toBe(0);
expect(vi.getTimerCount()).toBe(0);
});
it.each(['invalidate', 'clear'] as const)(
'%s detaches pending loads from the cache',
async (operation) => {
const old = deferred<string>();
const current = deferred<string>();
const load = vi
.fn()
.mockReturnValueOnce(old.promise)
.mockReturnValueOnce(current.promise);
const cache = createSingleFlightCache<string, string>({ ttlMs: 1000, load });
const first = cache.get('key');
cache[operation]('key');
const second = cache.get('key');
expect(load).toHaveBeenCalledTimes(2);
old.resolve('old');
await expect(first).resolves.toBe('old');
expect(cache.get('key')).toBe(second);
current.resolve('current');
await expect(second).resolves.toBe('current');
await expect(cache.get('key')).resolves.toBe('current');
expect(load).toHaveBeenCalledTimes(2);
},
);
it('does not let a late invalidated result overwrite a newer value', async () => {
const old = deferred<string>();
const load = vi.fn().mockReturnValueOnce(old.promise).mockResolvedValue('current');
const cache = createSingleFlightCache<string, string>({ ttlMs: 1000, load });
const first = cache.get('key');
cache.invalidate('key');
await expect(cache.get('key')).resolves.toBe('current');
old.resolve('old');
await first;
await expect(cache.get('key')).resolves.toBe('current');
});
it('caches within the TTL and reloads once expired', async () => {
let clock = 1000;
const load = vi.fn(async (k: string) => `${k}@${clock}`);
const cache = createSingleFlightCache<string, string>({
ttlMs: 100,
load,
now: () => clock,
});
expect(await cache.get('a')).toBe('a@1000');
clock = 1050; // still fresh (< 100)
expect(await cache.get('a')).toBe('a@1000');
expect(load).toHaveBeenCalledTimes(1);
clock = 1101; // 101ms since store → expired
expect(await cache.get('a')).toBe('a@1101');
expect(load).toHaveBeenCalledTimes(2);
});
it('de-dupes concurrent callers onto a single load (single-flight)', async () => {
const d = deferred<string>();
const load = vi.fn(() => d.promise);
const cache = createSingleFlightCache<string, string>({ ttlMs: 1000, load });
const p1 = cache.get('k');
const p2 = cache.get('k');
const p3 = cache.get('k');
expect(load).toHaveBeenCalledTimes(1); // only one load in flight
d.resolve('v');
expect(await Promise.all([p1, p2, p3])).toEqual(['v', 'v', 'v']);
expect(load).toHaveBeenCalledTimes(1);
});
it('keys independently — different keys load separately', async () => {
const load = vi.fn(async (k: number) => k * 2);
const cache = createSingleFlightCache<number, number>({ ttlMs: 1000, load });
expect(await cache.get(1)).toBe(2);
expect(await cache.get(2)).toBe(4);
expect(load).toHaveBeenCalledTimes(2);
});
it('force bypasses the freshness check but still joins an in-flight load', async () => {
let clock = 0;
const load = vi.fn(async (k: string) => `${k}@${clock}`);
const cache = createSingleFlightCache<string, string>({
ttlMs: 10_000,
load,
now: () => clock,
});
expect(await cache.get('a')).toBe('a@0'); // 1 load, cached fresh
clock = 5; // still fresh
expect(await cache.get('a', { force: true })).toBe('a@5'); // force → reload despite fresh
expect(load).toHaveBeenCalledTimes(2);
// force still single-flights: two concurrent forced gets share one load.
const d = deferred<string>();
load.mockImplementationOnce(() => d.promise);
const p1 = cache.get('a', { force: true });
const p2 = cache.get('a', { force: true });
expect(load).toHaveBeenCalledTimes(3); // not 4
d.resolve('shared');
expect(await Promise.all([p1, p2])).toEqual(['shared', 'shared']);
});
it('shouldCache=false leaves the entry uncached (next call reloads) but still de-dupes in-flight', async () => {
let n = 0;
const load = vi.fn(async () => ({ error: true as boolean, n: ++n }));
const cache = createSingleFlightCache({
ttlMs: 10_000,
load,
shouldCache: (v) => !v.error,
});
// concurrent callers still share one load...
const [a, b] = await Promise.all([cache.get('k'), cache.get('k')]);
expect(a).toEqual({ error: true, n: 1 });
expect(b).toEqual({ error: true, n: 1 });
expect(load).toHaveBeenCalledTimes(1);
// ...but nothing was cached, so the next call reloads.
expect(await cache.get('k')).toEqual({ error: true, n: 2 });
expect(load).toHaveBeenCalledTimes(2);
// a good result IS cached.
load.mockImplementationOnce(async () => ({ error: false, n: ++n }));
const good = await cache.get('k');
expect(good).toEqual({ error: false, n: 3 });
expect(await cache.get('k')).toEqual({ error: false, n: 3 }); // served from cache
expect(load).toHaveBeenCalledTimes(3);
});
it('a rejected load propagates to all joined callers, is not cached, and clears in-flight', async () => {
let attempt = 0;
const load = vi.fn(async () => {
attempt++;
if (attempt === 1) {
throw new Error('boom');
}
return 'ok';
});
const cache = createSingleFlightCache<string, string>({ ttlMs: 10_000, load });
const p1 = cache.get('k');
const p2 = cache.get('k');
await expect(p1).rejects.toThrow('boom');
await expect(p2).rejects.toThrow('boom'); // same in-flight, same rejection
expect(load).toHaveBeenCalledTimes(1);
// in-flight cleared + not cached → next call retries and succeeds.
expect(await cache.get('k')).toBe('ok');
expect(load).toHaveBeenCalledTimes(2);
});
it('invalidate drops a cached entry; clear drops all', async () => {
const load = vi.fn(async (k: string) => k.toUpperCase());
const cache = createSingleFlightCache<string, string>({ ttlMs: 10_000, load });
await cache.get('a');
await cache.get('b');
expect(load).toHaveBeenCalledTimes(2);
cache.invalidate('a');
await cache.get('a'); // reloads
await cache.get('b'); // still cached
expect(load).toHaveBeenCalledTimes(3);
cache.clear();
await cache.get('a');
await cache.get('b');
expect(load).toHaveBeenCalledTimes(5);
});
it('allows retry when a loader throws before returning its promise', async () => {
const load = vi
.fn<() => Promise<string>>()
.mockImplementationOnce(() => {
throw new Error('failed before request');
})
.mockResolvedValue('recovered');
const cache = createSingleFlightCache({ ttlMs: 1000, load });
await expect(cache.get('key')).rejects.toThrow('failed before request');
await expect(cache.get('key')).resolves.toBe('recovered');
expect(load).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,117 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
createLogger,
currentRequestId,
getLogLevel,
nextRequestId,
renderTraceBytes,
runWithoutRequestContext,
runWithRequestId,
runWithTraceRequest,
setLogLevel,
} from '../src/logger';
const originalLevel = getLogLevel();
afterEach(() => {
setLogLevel(originalLevel);
});
describe('TRACE diagnostics contract', () => {
it('renders every byte as continuous lowercase hexadecimal', () => {
expect(renderTraceBytes(new Uint8Array())).toBe('');
expect(renderTraceBytes(new Uint8Array([0x00, 0x01, 0x0f, 0x10, 0xff]))).toBe('00010f10ff');
});
it('renders only the addressed Uint8Array view', () => {
const backing = new Uint8Array([0xaa, 0x00, 0x0f, 0xff, 0xbb]);
expect(renderTraceBytes(backing.subarray(1, 4))).toBe('000fff');
expect(renderTraceBytes(Buffer.from(backing.buffer).subarray(2, 4))).toBe('0fff');
});
it('does not render bytes while TRACE is disabled', () => {
setLogLevel('info');
let rendered = false;
createLogger('Trace.Contract').trace(() => {
rendered = true;
return ['body=%s', renderTraceBytes(new Uint8Array([0xff]))];
});
expect(rendered).toBe(false);
});
it('creates distinct request contexts that survive asynchronous work', async () => {
setLogLevel('info');
const beforeDisabled = nextRequestId();
expect(runWithTraceRequest(() => currentRequestId())).toBeUndefined();
const afterDisabled = nextRequestId();
expect(afterDisabled).toBe(beforeDisabled + 1);
setLogLevel('trace');
const observeContext = () =>
runWithTraceRequest(async () => {
const before = currentRequestId();
await Promise.resolve();
const after = currentRequestId();
expect(after).toBe(before);
return after;
});
const first = await observeContext();
expect(currentRequestId()).toBeUndefined();
const second = await observeContext();
expect(currentRequestId()).toBeUndefined();
expect(first).toEqual(expect.any(Number));
expect(first).toBeGreaterThan(0);
expect(second).toEqual(expect.any(Number));
expect(second).toBeGreaterThan(0);
expect(second).not.toBe(first);
});
it('allocates once for nested TRACE contexts and reuses the request id', () => {
setLogLevel('trace');
const before = nextRequestId();
const observed = runWithTraceRequest(() => {
const outer = currentRequestId();
const inner = runWithTraceRequest(() => currentRequestId());
return { outer, inner };
});
const after = nextRequestId();
expect(observed.outer).toEqual(expect.any(Number));
expect(observed.inner).toBe(observed.outer);
expect(observed.outer).toBe(before + 1);
expect(after).toBe(before + 2);
expect(currentRequestId()).toBeUndefined();
});
it('detaches long-lived resources from the current request context', async () => {
await runWithRequestId(4243, async () => {
expect(currentRequestId()).toBe(4243);
const detached = await runWithoutRequestContext(async () => {
expect(currentRequestId()).toBeUndefined();
await Promise.resolve();
return currentRequestId();
});
expect(detached).toBeUndefined();
expect(currentRequestId()).toBe(4243);
});
expect(currentRequestId()).toBeUndefined();
});
it('reuses an existing request context even while TRACE is disabled', async () => {
setLogLevel('info');
await runWithRequestId(4242, async () => {
const observed = await runWithTraceRequest(async () => {
await Promise.resolve();
return currentRequestId();
});
expect(observed).toBe(4242);
});
});
});
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { isRealUin } from '../src/uin';
describe('isRealUin', () => {
it('accepts real QQ UINs (5..10 digits)', () => {
expect(isRealUin('10001')).toBe(true); // 5-digit floor
expect(isRealUin('123456789')).toBe(true);
expect(isRealUin('4294967295')).toBe(true); // uint32 max, 10 digits
});
it('rejects the empty string and "0"', () => {
expect(isRealUin('')).toBe(false);
expect(isRealUin('0')).toBe(false);
});
it('rejects too-short UINs (< 5 digits)', () => {
expect(isRealUin('1')).toBe(false);
expect(isRealUin('1234')).toBe(false);
});
it('rejects the garbage timestamp-shaped UINs from issue #162 (11+ digits)', () => {
expect(isRealUin('1701414379536')).toBe(false); // 13-digit ms timestamp
expect(isRealUin('1614019567632')).toBe(false);
expect(isRealUin('2014024115056')).toBe(false);
expect(isRealUin('12345678901')).toBe(false); // 11 digits — first rejected width
});
it('rejects non-digit strings (path traversal / injection guards)', () => {
expect(isRealUin('12345a')).toBe(false);
expect(isRealUin('12 345')).toBe(false);
expect(isRealUin('../etc')).toBe(false);
expect(isRealUin('123456\n')).toBe(false);
});
it('guarantees a BigInt-safe pure-digit string on accept', () => {
const uin = '123456789';
expect(isRealUin(uin)).toBe(true);
expect(() => BigInt(uin)).not.toThrow();
});
});
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { normalizeMirrorUrl, normalizeUpdateSourceConfig } from '../src/update-source';
import { normalizeRuntimeConfig } from '../src/runtime';
describe('update source configuration', () => {
it('normalizes proxy prefixes, deduplicates mirrors and retains order', () => {
expect(
normalizeUpdateSourceConfig({
mode: 'auto',
mirror: '',
mirrors: [
' https://EXAMPLE.com/proxy/ ',
'https://example.com/proxy',
'https://other.example',
],
}),
).toEqual({
mode: 'auto',
mirror: '',
mirrors: ['https://example.com/proxy', 'https://other.example'],
});
});
it.each([
'',
'file:///tmp/x',
'ftp://example.com',
'https://user:secret@example.com',
'https://example.com/?token=a',
'https://example.com/#a',
null,
])('rejects invalid mirror %s', (url) => {
expect(() => normalizeMirrorUrl(url)).toThrow();
});
it.each([
null,
[],
{ mode: ['auto'], mirrors: [] },
{ mode: 'mirror', mirror: '', mirrors: [] },
{ mode: 'auto', mirrors: Array(17).fill('https://example.com') },
])('rejects invalid configuration %j', (config) => {
expect(() => normalizeUpdateSourceConfig(config)).toThrow();
});
it('retains configured update sources when normalizing runtime settings', () => {
const updateSource = { mode: 'direct', mirror: '', mirrors: [] };
expect(normalizeRuntimeConfig({ updateSource, webuiPort: 7777 })).toMatchObject({
updateSource,
webuiPort: 7777,
});
expect(normalizeRuntimeConfig({})).not.toHaveProperty('updateSource');
});
});
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import {
defaultWebuiPresentation,
injectWebuiTarget,
parseWebuiPresentation,
resolveWebuiTarget,
} from '../src/webui-target';
describe('deployment presentation contract', () => {
it('requires explicit opt-in and rejects misspelled deployments', () => {
expect(resolveWebuiTarget(undefined)).toBeNull();
expect(resolveWebuiTarget(' ')).toBeNull();
expect(resolveWebuiTarget('maibot')).toBe('maibot');
expect(() => resolveWebuiTarget('mai-bot')).toThrow('SNOWLUMA_WEBUI_TARGET');
expect(() => resolveWebuiTarget('__proto__')).toThrow('SNOWLUMA_WEBUI_TARGET');
});
it('keeps the unconfigured document intact and injects before cached appearance', () => {
const html = '<html><head><script>readSavedAppearance()</script></head></html>';
expect(injectWebuiTarget(html, null)).toBe(html);
const injected = injectWebuiTarget(html, 'maibot');
expect(injected.indexOf('snowluma-webui-target')).toBeLessThan(
injected.indexOf('readSavedAppearance'),
);
expect(injected).toContain(JSON.stringify(defaultWebuiPresentation('maibot')));
expect(() => injectWebuiTarget('broken html', 'maibot')).toThrow('missing <head>');
});
it('validates the version, target, style and color mode without accepting arbitrary CSS', () => {
const valid = defaultWebuiPresentation('maibot');
expect(parseWebuiPresentation(valid)).toEqual(valid);
expect(
parseWebuiPresentation({ ...valid, mode: 'dark', css: 'body{display:none}' }),
).toEqual({ ...valid, mode: 'dark' });
expect(parseWebuiPresentation({ version: 1, target: null })).toEqual({
version: 1,
target: null,
});
for (const input of [
null,
{},
{ ...valid, version: 2 },
{ ...valid, target: 'other' },
{ ...valid, style: 'modern' },
{ ...valid, mode: 'blue' },
]) {
expect(() => parseWebuiPresentation(input)).toThrow();
}
});
});
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["node"],
"noEmit": true
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules"
]
}
+10
View File
@@ -0,0 +1,10 @@
// Vitest config for @snowluma/common. Pure-TS package (no proton), so a
// plain node-environment runner over tests/ is enough.
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
environment: 'node',
},
});
+16
View File
@@ -0,0 +1,16 @@
# Per-instance runtime state written by the dev server / production daemon.
# `runtime.json` is the only tracked file (the deploy-time overrides for
# webuiPort, hookAutoLoad, etc); everything else here is operator-private:
# * webui.json — admin password hash + rotation state
# * consent.json — EULA/PRIVACY acceptance per agreement version
# * notifications.json — per-account webhook destinations
# * onebot_<uin>.json — per-uin OneBot transport binding (HTTP/WS ports etc)
# * ui.json — WebUI panel layout per operator
#
# Without this ignore file, every `pnpm dev:mode` / `node dist/index.mjs`
# leaves a 5-file untracked surface in `packages/core/config/` that pollutes
# `git status` and would accidentally commit local creds if `git add -A` is used.
*
!.gitignore
!runtime.json
+3
View File
@@ -0,0 +1,3 @@
{
"webuiPort": 8080
}
+70
View File
@@ -0,0 +1,70 @@
{
"license": "SEE LICENSE IN LICENSE",
"name": "@snowluma/core",
"version": "2.4.2",
"description": "SnowLuma core - NTQQ packet sniffer with Node.js native addon",
"private": true,
"type": "module",
"main": "../../dist/index.mjs",
"exports": {
".": {
"types": "./src/index.ts",
"default": "../../dist/index.mjs"
},
"./bridge": {
"types": "./src/bridge/bridge.ts",
"default": "./src/bridge/bridge.ts"
},
"./bridge-interface": {
"types": "./src/bridge/bridge-interface.ts",
"default": "./src/bridge/bridge-interface.ts"
},
"./bridge-context": {
"types": "./src/bridge/bridge-context.ts",
"default": "./src/bridge/bridge-context.ts"
},
"./manager": {
"types": "./src/bridge/manager.ts",
"default": "./src/bridge/manager.ts"
},
"./apis": {
"types": "./src/bridge/apis/index.ts",
"default": "./src/bridge/apis/index.ts"
}
},
"scripts": {
"build": "vite build",
"build:all": "cross-env BUILD_WEBUI=true pnpm build",
"dev": "tsx src/index.ts",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@snowluma/bridge": "workspace:*",
"@snowluma/common": "workspace:*",
"@snowluma/onebot": "workspace:*",
"@snowluma/proto-defs": "workspace:*",
"@snowluma/protocol": "workspace:*",
"@snowluma/proton": "workspace:*",
"@snowluma/websocket": "workspace:*",
"fflate": "^0.8.3",
"otpauth": "^9.5.1",
"tar": "^7.5.22"
},
"devDependencies": {
"@hono/node-server": "^2.1.4",
"@types/node": "catalog:",
"cross-env": "^10.1.0",
"hono": "^4.13.11",
"tsx": "^4.21.0",
"typescript": "catalog:",
"vite": "catalog:",
"vite-plugin-cp": "^6.0.3",
"vitest": "4.1.12",
"@snowluma/sqlite": "workspace:*"
},
"engines": {
"node": "^24.22.1 || >=26.12.1"
}
}
@@ -0,0 +1,28 @@
import { GetPskey } from '@snowluma/protocol/oidb-services/web/get-pskey';
import { getCollectionList, type CollectionListResult } from '@snowluma/protocol/web/collection';
import type { BridgeContext } from '../bridge-context';
const COLLECTION_DOMAIN = 'weiyun.com';
/** Read-only access to the account's QQ collection list. */
export class CollectionApi {
constructor(private readonly ctx: BridgeContext) {}
async list(category = 0, count = 50): Promise<CollectionListResult> {
const { domainPskeyMap } = await GetPskey.invoke(this.ctx, {
domainList: [COLLECTION_DOMAIN],
});
const pskey = domainPskeyMap.get(COLLECTION_DOMAIN);
if (!pskey) {
throw new Error(`QQ did not return a p_skey for ${COLLECTION_DOMAIN}`);
}
return getCollectionList({
uin: this.ctx.identity.uin,
pskey,
category,
count,
});
}
}
+734
View File
@@ -0,0 +1,734 @@
import { GroupInviteTracker } from '@snowluma/protocol/group-invite-tracker';
import { groupRequestSubType } from '@snowluma/protocol/qq-info';
import { FetchDownloadRkeys } from '@snowluma/protocol/oidb-services/contacts/fetch-download-rkeys';
import {
FetchFriendListPage,
type FriendListPageCategory,
type FriendListPageEntry,
} from '@snowluma/protocol/oidb-services/contacts/fetch-friend-list-page';
import { FetchGroupDetail } from '@snowluma/protocol/oidb-services/contacts/fetch-group-detail';
import { FetchGroupList } from '@snowluma/protocol/oidb-services/contacts/fetch-group-list';
import { FetchGroupMemberListPage } from '@snowluma/protocol/oidb-services/contacts/fetch-group-member-list-page';
import {
FetchGroupRequests,
FetchGroupRequestsByUid,
groupRequestNotifyType,
type DecodedGroupRequest,
} from '@snowluma/protocol/oidb-services/contacts/fetch-group-requests';
import {
FetchRobotUinRanges,
type RobotUinRange,
type RobotUinRangeSnapshot,
} from '@snowluma/protocol/oidb-services/contacts/fetch-robot-uin-ranges';
import { FetchUserProfile } from '@snowluma/protocol/oidb-services/contacts/fetch-user-profile';
import { FetchUserProfileByUid } from '@snowluma/protocol/oidb-services/contacts/fetch-user-profile-by-uid';
import {
fetchQidianCorpInfo as fetchQidianCorpInfoWire,
type QidianCorpInfo,
} from '@snowluma/protocol/services/qidian/fetch-corp-info';
import { GetBuddyRecommendArk } from '@snowluma/protocol/oidb-services/contacts/get-buddy-recommend-ark';
import { GetGroupRecommendArk } from '@snowluma/protocol/oidb-services/contacts/get-group-recommend-ark';
import { GetGroupShareLink } from '@snowluma/protocol/oidb-services/contacts/get-group-share-link';
import {
SendTuwenArk,
type SendTuwenArkParams,
} from '@snowluma/protocol/oidb-services/contacts/send-tuwen-ark';
import { SetFriendCategory } from '@snowluma/protocol/oidb-services/contacts/set-friend-category';
import { toHex } from '@snowluma/common/hex';
import { createLogger } from '@snowluma/common/logger';
import {
createSingleFlightCache,
type SingleFlightCache,
} from '@snowluma/common/single-flight-cache';
import type {
FriendCategoryInfo,
FriendInfo,
GroupMemberInfo,
GroupRequestInfo,
PublicProfileFields,
QQGroupInfo,
UserProfileInfo,
} from '@snowluma/protocol/qq-info';
import type { DownloadRKeyInfo } from '../bridge';
import type { BridgeContext } from '../bridge-context';
const log = createLogger('Bridge.Contacts');
// Official client stores group mute as an expire timestamp (internal 60027 /
// JS groupShutupExpireTime): 0 = off, 0xFFFFFFFF = permanent, otherwise unix
// seconds. `> 0` is wrong — a leftover past expire still looks "on".
function isGroupAllMuted(expireTs?: number, nowSec = Math.floor(Date.now() / 1000)): boolean {
return (expireTs ?? 0) > nowSec;
}
interface FriendRoster {
entries: FriendListPageEntry[];
categories: FriendListPageCategory[];
}
export interface SetFriendCategoryParams {
uin: number;
categoryId?: number;
categoryName?: string;
}
export function isRobotUin(uin: number, ranges: readonly RobotUinRange[]): boolean {
return (
Number.isInteger(uin) &&
uin > 0 &&
ranges.some(({ minUin, maxUin }) => uin >= minUin && uin <= maxUin)
);
}
function groupRequestMetadata(
raw: Pick<
DecodedGroupRequest,
'sequence' | 'state' | 'eventType' | 'comment' | 'operateTransInfo'
>,
groupId: number,
filtered: boolean,
) {
const sequence = Number(raw.sequence ?? 0);
if (!Number.isSafeInteger(sequence) || sequence <= 0) {
throw new Error(
`invalid group-request sequence: group=${groupId} sequence=${String(raw.sequence ?? 0)}`,
);
}
const eventType = raw.eventType ?? 0;
const notifyType = groupRequestNotifyType(eventType);
if (notifyType === undefined) {
log.warn(
'unsupported group-request wire type: group=%d sequence=%d type=%d',
groupId,
sequence,
eventType,
);
}
return {
sequence,
state: raw.state ?? 0,
eventType,
notifyType,
comment: raw.comment ?? '',
filtered,
operateTransInfo: raw.operateTransInfo?.length ? raw.operateTransInfo : undefined,
};
}
const MEMBER_LIST_TTL_MS = 60_000;
const FRIEND_PROFILE_TTL_MS = 5 * 60_000;
const FRIEND_PROFILE_CONCURRENCY = 4;
export interface FriendListOptions {
/** Explicitly query profiles; ordinary roster refreshes never fan out. */
withProfile?: boolean;
}
export class ContactsApi {
/**
* Per-group TTL + single-flight cache for `fetchGroupMemberList`, keyed by
* groupId, living for the lifetime of the Bridge.
*
* Without this, a busy OneBot client (e.g. MaiBot calling
* `get_group_member_info` once per inbound message) triggers one
* OIDB 0xfe7_3 per chat message — sustained >1k/h, which trips
* Tencent risk-control and gets the account banned for 7 days.
*/
private readonly memberListCache: SingleFlightCache<number, GroupMemberInfo[]> =
createSingleFlightCache({
ttlMs: MEMBER_LIST_TTL_MS,
load: (groupId) => this.fetchGroupMemberListUncached(groupId),
});
private robotUinRangesPromise_: Promise<RobotUinRangeSnapshot> | null = null;
readonly groupInvites = new GroupInviteTracker();
private friendRosterGeneration = 0;
private rememberedFriendRosterGeneration = 0;
private activeProfiles = 0;
private readonly profileWaiters: (() => void)[] = [];
private readonly friendProfiles: SingleFlightCache<number, PublicProfileFields> =
createSingleFlightCache({
ttlMs: FRIEND_PROFILE_TTL_MS,
load: (uin) => this.loadFriendProfile(uin),
});
constructor(private readonly ctx: BridgeContext) {}
dispose(): void {
// Late roster responses must not start new profile loads after shutdown.
this.memberListCache.dispose();
this.friendProfiles.dispose();
}
private fetchRobotUinRanges(groupId: number): Promise<RobotUinRangeSnapshot> {
if (this.robotUinRangesPromise_) {
return this.robotUinRangesPromise_;
}
const pending = FetchRobotUinRanges.invoke(this.ctx).then((snapshot) => {
log.info(
'loaded robot UIN ranges uin=%s version=%d count=%d',
this.ctx.identity.uin,
snapshot.version,
snapshot.ranges.length,
);
return snapshot;
});
this.robotUinRangesPromise_ = pending;
// A failed first load must be observable and retryable. Keep the rejection
// visible to the caller instead of converting every member to false.
void pending.catch((err: unknown) => {
if (this.robotUinRangesPromise_ === pending) {
this.robotUinRangesPromise_ = null;
}
log.error(
'failed to load robot UIN ranges: uin=%s group=%d err=%s',
this.ctx.identity.uin,
groupId,
err instanceof Error ? (err.stack ?? err.message) : String(err),
);
});
return pending;
}
/** Server-built ARK share card (JSON string) recommending a friend. */
getBuddyRecommendArk(userId: number, phoneNumber = ''): Promise<string> {
return GetBuddyRecommendArk.invoke(this.ctx, { userId, phoneNumber });
}
/** Server-built ARK share card (JSON string) recommending a group. */
getGroupRecommendArk(groupId: number): Promise<string> {
return GetGroupRecommendArk.invoke(this.ctx, { groupId });
}
getGroupShareLink(
groupId: number,
needShortUrl = true,
srcId = 73,
additionalParam = '',
): Promise<string> {
return GetGroupShareLink.invoke(this.ctx, {
groupId,
needShortUrl,
srcId,
additionalParam,
});
}
/** Send a custom 图文 ark card to a C2C peer or group (0xdc2_34). */
sendTuwenArk(params: SendTuwenArkParams): Promise<void> {
return SendTuwenArk.invoke(this.ctx, params);
}
private async loadFriendProfile(uin: number): Promise<PublicProfileFields> {
if (this.activeProfiles >= FRIEND_PROFILE_CONCURRENCY) {
await new Promise<void>((resolve) => this.profileWaiters.push(resolve));
} else {
this.activeProfiles++;
}
try {
const profile = await this.fetchUserProfile(uin);
return profile.profile ?? {};
} catch (cause) {
log.warn(
'friend profile query failed: user=%d error=%s',
uin,
cause instanceof Error ? cause.message : String(cause),
);
throw new Error(`friend profile query failed for ${uin}`, { cause });
} finally {
const next = this.profileWaiters.shift();
if (next) {
next();
} else {
this.activeProfiles--;
}
}
}
private async enrichFriendProfiles(entries: FriendListPageEntry[]): Promise<void> {
const started = Date.now();
for (let offset = 0; offset < entries.length; offset += FRIEND_PROFILE_CONCURRENCY) {
const batch = entries.slice(offset, offset + FRIEND_PROFILE_CONCURRENCY);
const results = await Promise.allSettled(
batch.map(async ({ friend }) => {
const extra = await this.friendProfiles.get(friend.uin);
friend.profile = { ...structuredClone(extra), ...friend.profile };
}),
);
const errors = results.filter(
(result): result is PromiseRejectedResult => result.status === 'rejected',
);
if (errors.length > 0) {
log.warn(
'friend profile enrichment failed: batch=%d failures=%d elapsedMs=%d',
offset,
errors.length,
Date.now() - started,
);
throw new AggregateError(
errors.map((result) => result.reason),
'friend profile enrichment failed',
);
}
}
log.debug(
'friend profiles loaded: friends=%d elapsedMs=%d',
entries.length,
Date.now() - started,
);
}
private async fetchFriendRoster(options: FriendListOptions = {}): Promise<FriendRoster> {
const generation = ++this.friendRosterGeneration;
const entries: FriendListPageEntry[] = [];
const categories = new Map<number, FriendListPageCategory>();
const seenCookies = new Set<string>();
let cookie: Uint8Array | undefined;
for (;;) {
const page = await FetchFriendListPage.invoke(this.ctx, { cookie });
entries.push(...page.entries);
for (const category of page.categories) {
categories.set(category.categoryId, category);
}
const next = page.cookie;
if (!next?.length) {
break;
}
const key = toHex(next);
if (seenCookies.has(key)) {
throw new Error(`repeated friend-list cookie ${key}`);
}
seenCookies.add(key);
cookie = next;
}
if (options.withProfile) {
await this.enrichFriendProfiles(entries);
}
if (generation > this.rememberedFriendRosterGeneration) {
this.ctx.identity.rememberFriends(entries.map((entry) => entry.friend));
this.rememberedFriendRosterGeneration = generation;
}
return { entries, categories: [...categories.values()] };
}
async fetchFriendList(options: FriendListOptions = {}): Promise<FriendInfo[]> {
const roster = await this.fetchFriendRoster(options);
return roster.entries.map((entry) => entry.friend);
}
async fetchFriendCategories(options: FriendListOptions = {}): Promise<FriendCategoryInfo[]> {
const roster = await this.fetchFriendRoster(options);
const buckets = new Map(
roster.categories.map((category) => [category.categoryId, [] as FriendInfo[]]),
);
for (const entry of roster.entries) {
const bucket = buckets.get(entry.categoryId);
if (!bucket) {
throw new Error(
`friend roster references missing category ${entry.categoryId} ` +
`(${roster.entries.length} friends total)`,
);
}
bucket.push(entry.friend);
}
return roster.categories.map((category) => {
const friends = buckets.get(category.categoryId)!;
if (friends.length !== category.memberCount) {
log.warn(
'friend category member count mismatch category=%d server=%d assembled=%d',
category.categoryId,
category.memberCount,
friends.length,
);
}
return { ...category, friends };
});
}
async setFriendCategory(params: SetFriendCategoryParams): Promise<void> {
const selectorCount =
Number(params.categoryId !== undefined) + Number(params.categoryName !== undefined);
if (selectorCount !== 1) {
throw new Error('exactly one of categoryId or categoryName is required');
}
const categories = await this.fetchFriendCategories();
const friendCategory = categories.find((category) =>
category.friends.some((friend) => friend.uin === params.uin),
);
const friend = friendCategory?.friends.find((entry) => entry.uin === params.uin);
if (!friend) {
throw new Error(`friend ${params.uin} is not in the live roster`);
}
if (!friend.uid) {
throw new Error(`friend ${params.uin} has no UID in the live roster`);
}
let target: FriendCategoryInfo | undefined;
if (params.categoryId !== undefined) {
target = categories.find((category) => category.categoryId === params.categoryId);
if (!target) {
throw new Error(`friend category ${params.categoryId} does not exist`);
}
} else {
const matches = categories.filter(
(category) => category.categoryName === params.categoryName,
);
if (matches.length === 0) {
throw new Error(`friend category name "${params.categoryName}" does not exist`);
}
if (matches.length > 1) {
throw new Error(`friend category name "${params.categoryName}" is ambiguous`);
}
target = matches[0]!;
}
await SetFriendCategory.invoke(this.ctx, {
uid: friend.uid,
categoryId: target.categoryId,
});
log.info(
'friend category updated: uin=%s friend=%d category=%d',
this.ctx.identity.uin,
params.uin,
target.categoryId,
);
}
async fetchGroupList(): Promise<QQGroupInfo[]> {
const resp = await FetchGroupList.invoke(this.ctx);
const groups: QQGroupInfo[] = [];
for (const raw of resp.groups ?? []) {
groups.push({
groupId: raw.groupUin ?? 0,
groupName: raw.info?.groupName ?? '',
remark: raw.customInfo?.remark ?? '',
memberCount: raw.info?.memberCount ?? 0,
memberMax: raw.info?.memberMax ?? 0,
members: new Map(),
// #197: the list already carries these — no extra fetch needed. `level`
// is not in the list (0x88D_0 detail only), so it stays undefined here.
createTime: raw.info?.createdTime ?? 0,
memo: raw.info?.announcement || raw.info?.description || '',
// #490: expose the description alongside `memo`. QQ fills in either
// field alone, so folding both into `memo` made the description
// unreachable whenever an announcement was also set.
description: raw.info?.description ?? '',
allMuted: isGroupAllMuted(raw.info?.shutUpAllTimestamp),
});
}
this.ctx.identity.rememberGroups(groups);
return groups;
}
/**
* Fetch a single group's public detail by id via `0x88D_0` — works even for a
* group the bot hasn't joined (used to resolve a group-invite's name). Returns
* null when the server has no such group / denies the lookup. Deliberately
* does NOT `rememberGroups` it — a non-member group must not pollute the
* joined-groups roster / get_group_list.
*/
async fetchGroupDetail(groupId: number): Promise<QQGroupInfo | null> {
if (!(groupId > 0)) {
return null;
}
const resp = await FetchGroupDetail.invoke(this.ctx, { groupUin: groupId });
const r = resp.groupInfo?.results;
if (!r) {
return null;
}
return {
groupId,
groupName: r.name ?? '',
remark: '',
memberCount: Number(r.memberCount ?? 0n),
memberMax: Number(r.maxMemberCount ?? 0n),
members: new Map(),
// #197: the detail is the only source of `level`; it also carries
// createTime + the notice preview (memo). `description` is deliberately
// left unset — 0x88D_0 has no description tag, so for a group the bot
// hasn't joined there is nothing to report (#490).
createTime: Number(r.createTime ?? 0n),
level: Number(r.level ?? 0n),
memo: r.noticePreview ?? '',
allMuted: isGroupAllMuted(r.shutUpAllTimestamp),
};
}
fetchGroupMemberList(
groupId: number,
options: { force?: boolean } = {},
): Promise<GroupMemberInfo[]> {
return this.memberListCache.get(groupId, { force: options.force });
}
private async fetchGroupMemberListUncached(groupId: number): Promise<GroupMemberInfo[]> {
// QQ's native group service obtains the robot classification separately
// from 0xFE7_3. Start both reads together so the metadata adds no serial
// round trip to paginated member fetches.
const robotSnapshotPromise = this.fetchRobotUinRanges(groupId);
const members: GroupMemberInfo[] = [];
let token = '';
do {
const page = await FetchGroupMemberListPage.invoke(this.ctx, { groupId, token });
members.push(...page.members);
token = page.token;
} while (token);
const robotSnapshot = await robotSnapshotPromise;
for (const member of members) {
member.isRobot = isRobotUin(member.uin, robotSnapshot.ranges);
}
this.ctx.identity.rememberGroupMembers(groupId, members);
return members;
}
async fetchUserProfile(uin: number): Promise<UserProfileInfo> {
const info = await FetchUserProfile.invoke(this.ctx, { uin });
this.ctx.identity.rememberUserProfile(info);
return info;
}
/** Look up a user profile by UID (string form). Used for strangers
* whose UIN we don't have yet — typically the requester on a
* group join request push. Mirrors Lagrange's `FetchUserInfoEvent
* .Create(targetUid)` path. */
async fetchUserProfileByUid(uid: string): Promise<UserProfileInfo> {
const info = await FetchUserProfileByUid.invoke(this.ctx, { uid });
if (info.uin > 0) {
this.ctx.identity.rememberUserProfile(info);
}
return info;
}
/** 企点企业资料卡(#404 后续 PR)。仅在判断命中的是企点账号后再调用。
* best-effort:非企点账号或拉取失败返回 null,不影响上层资料读取。 */
async fetchQidianCorpInfo(uin: number): Promise<QidianCorpInfo | null> {
return fetchQidianCorpInfoWire(this.ctx, uin);
}
async fetchGroupRequests(
filtered = false,
count = 50,
cursor = 0n,
): Promise<GroupRequestInfo[]> {
const resp = await FetchGroupRequests.invoke(this.ctx, { filtered, count, cursor });
const requests: GroupRequestInfo[] = [];
for (const raw of resp.requests ?? []) {
const groupId = raw.group?.groupUin ?? 0;
const targetUin = raw.target?.uin ?? 0;
const invitorUin = raw.invitor?.uin ?? 0;
const operatorUin = raw.operatorUser?.uin ?? 0;
const targetUid =
raw.target?.uid ||
this.ctx.identity.findUidByUin(targetUin, groupId) ||
this.ctx.identity.findUidByUin(targetUin) ||
'';
const invitorUid =
raw.invitor?.uid ||
this.ctx.identity.findUidByUin(invitorUin, groupId) ||
this.ctx.identity.findUidByUin(invitorUin) ||
'';
const operatorUid =
raw.operatorUser?.uid ||
this.ctx.identity.findUidByUin(operatorUin, groupId) ||
this.ctx.identity.findUidByUin(operatorUin) ||
'';
requests.push({
groupId,
groupName: raw.group?.groupName ?? '',
targetUid,
targetUin:
targetUin ||
this.ctx.identity.findUinByUid(targetUid, groupId) ||
this.ctx.identity.findUinByUid(targetUid) ||
0,
targetName: raw.target?.name ?? '',
invitorUid,
invitorUin:
invitorUin ||
this.ctx.identity.findUinByUid(invitorUid, groupId) ||
this.ctx.identity.findUinByUid(invitorUid) ||
0,
invitorName: raw.invitor?.name ?? '',
operatorUid,
operatorUin:
operatorUin ||
this.ctx.identity.findUinByUid(operatorUid, groupId) ||
this.ctx.identity.findUinByUid(operatorUid) ||
0,
operatorName: raw.operatorUser?.name ?? '',
...groupRequestMetadata(raw, groupId, filtered),
});
}
this.ctx.identity.rememberGroupRequests(requests);
return requests;
}
/** UID-form request list retained for correlating incoming UID-only pushes.
* Explicit list/actions use fetchGroupRequests(), which matches QQ's native
* numeric-account path and does not need profile lookups. */
async fetchGroupRequestsByUid(
filtered = false,
count = 50,
cursor = 0n,
): Promise<GroupRequestInfo[]> {
const resp = await FetchGroupRequestsByUid.invoke(this.ctx, { filtered, count, cursor });
const requests: GroupRequestInfo[] = [];
for (const raw of resp.requests ?? []) {
const groupId = raw.group?.groupUin ?? 0;
const targetUid = raw.target?.uid ?? '';
const invitorUid = raw.invitor?.uid ?? '';
const operatorUid = raw.operatorUser?.uid ?? '';
requests.push({
groupId,
groupName: raw.group?.groupName ?? '',
targetUid,
targetUin:
this.ctx.identity.findUinByUid(targetUid, groupId) ??
this.ctx.identity.findUinByUid(targetUid) ??
0,
targetName: raw.target?.name ?? '',
invitorUid,
invitorUin:
this.ctx.identity.findUinByUid(invitorUid, groupId) ??
this.ctx.identity.findUinByUid(invitorUid) ??
0,
invitorName: raw.invitor?.name ?? '',
operatorUid,
operatorUin:
this.ctx.identity.findUinByUid(operatorUid, groupId) ??
this.ctx.identity.findUinByUid(operatorUid) ??
0,
operatorName: raw.operatorUser?.name ?? '',
...groupRequestMetadata(raw, groupId, filtered),
});
}
this.ctx.identity.rememberGroupRequests(requests);
return requests;
}
async resolveGroupJoinRequest(
groupId: number,
uid: string,
subType: 'add' | 'invite',
invitedUid?: string,
): Promise<GroupRequestInfo | null> {
const [main, filtered] = await Promise.allSettled([
this.fetchGroupRequestsByUid(false),
this.fetchGroupRequestsByUid(true),
]);
if (main.status === 'rejected') {
throw main.reason;
}
if (filtered.status === 'rejected') {
throw filtered.reason;
}
return (
[...main.value, ...filtered.value].find((request) => {
if (
request.groupId !== groupId ||
request.state !== 1 ||
groupRequestSubType(request) !== subType
) {
return false;
}
return subType === 'add'
? request.targetUid === uid
: request.invitorUid === uid && request.targetUid === invitedUid;
}) ?? null
);
}
/** Returns whether this live card introduces a new approval sequence. */
rememberGroupInviteCardSequence(groupId: number, sequence: number): boolean {
return this.groupInvites.receiveCard(groupId, sequence);
}
getGroupInviteCardSequence(groupId: number): number | undefined {
return this.groupInvites.getCardSequence(groupId);
}
findGroupInviteCardGroupBySequence(sequence: number, groupId?: number): number | undefined {
return this.groupInvites.findCardGroup(sequence, groupId);
}
async fetchDownloadRKeys(): Promise<DownloadRKeyInfo[]> {
const resp = await FetchDownloadRkeys.invoke(this.ctx);
const result: DownloadRKeyInfo[] = [];
for (const entry of resp.downloadRkey?.rkeys ?? []) {
const rkey = entry.rkey ?? '';
const type = entry.type ?? 0;
if (rkey && type) {
result.push({
rkey,
ttlSeconds: Number(entry.rkeyTtlSec ?? 0),
storeId: entry.storeId ?? 0,
createTime: entry.rkeyCreateTime ?? 0,
type,
});
}
}
return result;
}
}
+437
View File
@@ -0,0 +1,437 @@
import { FetchAiVoice } from '@snowluma/protocol/oidb-services/extras/fetch-ai-voice';
import {
FetchAiVoiceList,
type AiVoiceCategory as NamespaceAiVoiceCategory,
} from '@snowluma/protocol/oidb-services/extras/fetch-ai-voice-list';
import {
GetStrangerStatus,
type StrangerStatus as NamespaceStrangerStatus,
} from '@snowluma/protocol/oidb-services/extras/get-stranger-status';
import {
GetGroupTodoList,
type GroupTodoListItem as NamespaceGroupTodoListItem,
} from '@snowluma/protocol/oidb-services/extras/get-group-todo-list';
import { GroupTodo } from '@snowluma/protocol/oidb-services/extras/group-todo';
import { convertAudioBytes } from '@snowluma/protocol/highway/ffmpeg-addon';
import { loadBinarySource } from '@snowluma/protocol/highway/utils';
import { createLogger } from '@snowluma/common/logger';
import type { QQEventVariant } from '@snowluma/protocol/events';
import type { PttTransReq, PttTransResp } from '@snowluma/proto-defs/ptt-trans';
import { protobuf_decode, protobuf_encode } from '@snowluma/proton';
import type { BridgeContext } from '../bridge-context';
import type { MediaIndexNode } from './shared';
/** Inputs for a voice-to-text request, gathered from the received `record`
* element + its message meta. `uuid`/`md5Hex` come from the cached record;
* the uins + scene from the message meta. */
export interface PttTransInput {
isGroup: boolean;
msgId: number;
senderUin: number;
/** Receiver uin (c2c) or group uin (group). */
peerUin: number;
uuid: string;
md5Hex: string;
duration: number;
size: number;
format: number;
/** Group ptt numeric file id (group only; optional). */
fileId?: number;
}
// ─────────────── public types (re-exported from bridge.ts as before) ───
export type StrangerStatus = NamespaceStrangerStatus;
export type GroupTodoListItem = NamespaceGroupTodoListItem;
export const AiVoiceChatType = {
Unknown: 0,
Sound: 1,
Sing: 2,
} as const;
export type AiVoiceChatType = (typeof AiVoiceChatType)[keyof typeof AiVoiceChatType];
export type AiVoiceCategory = NamespaceAiVoiceCategory;
export interface AiVoiceItem {
voiceId: string;
voiceDisplayName: string;
voiceExampleUrl: string;
}
export interface AiVoiceSendReceipt {
sequence: number;
}
const log = createLogger('Bridge.Extras');
const AI_VOICE_RECEIPT_TIMEOUT_MS = 5_000;
const AI_VOICE_CANDIDATE_LIMIT = 64;
const AI_VOICE_CLAIM_LIMIT = 256;
type GroupMessageEvent = Extract<QQEventVariant, { kind: 'group_message' }>;
function normalizeMediaUuid(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function matchesAiVoiceEvent(node: MediaIndexNode, event: GroupMessageEvent): boolean {
const expectedUuid = normalizeMediaUuid(node.fileUuid);
if (!expectedUuid) {
return false;
}
for (const element of event.elements) {
if (element.type !== 'record') {
continue;
}
const actualUuid = normalizeMediaUuid(element.mediaNode?.fileUuid || element.fileId);
if (actualUuid === expectedUuid) {
return true;
}
}
return false;
}
function describeAiVoiceFingerprint(node: MediaIndexNode): string {
const uuid = normalizeMediaUuid(node.fileUuid);
if (uuid) {
return `uuid:${uuid.slice(-12)}`;
}
return 'missing';
}
export class ExtrasApi {
private readonly claimedAiVoiceReceiptKeys = new Set<string>();
constructor(private readonly ctx: BridgeContext) {}
// ─────────────── Group todo query / mutations (0x9474 / 0xF90) ───────────────
async getGroupTodoList(groupId: number): Promise<GroupTodoListItem[]> {
const items = await GetGroupTodoList.invoke(this.ctx, { groupId });
log.debug('group todo list fetched group=%d count=%d', groupId, items.length);
return items;
}
setGroupTodo(groupId: number, msgSeq: bigint): Promise<void> {
return GroupTodo.invoke(this.ctx, { groupId, msgSeq, action: 'set' });
}
completeGroupTodo(groupId: number, msgSeq: bigint): Promise<void> {
return GroupTodo.invoke(this.ctx, { groupId, msgSeq, action: 'complete' });
}
cancelGroupTodo(groupId: number, msgSeq: bigint): Promise<void> {
return GroupTodo.invoke(this.ctx, { groupId, msgSeq, action: 'cancel' });
}
// ─────────────── Stranger online/ext status (0xFE1_2) ───────────────
/**
* Keeps the action response user-safe while retaining the underlying
* failure in the service log for diagnosis.
*/
async getStrangerStatus(uin: number): Promise<StrangerStatus | null> {
try {
const status = await GetStrangerStatus.invoke(this.ctx, { uin });
if (!status) {
log.warn('user status query returned no status target=%d', uin);
}
return status;
} catch (error) {
log.warn(
'user status query failed target=%d: %s',
uin,
error instanceof Error ? error.message : String(error),
);
return null;
}
}
// ─────────────── AI voice (0x929D / 0x929B) ───────────────
fetchAiVoiceList(
groupId: number,
chatType: AiVoiceChatType | number,
): Promise<AiVoiceCategory[]> {
return FetchAiVoiceList.invoke(this.ctx, { groupId, chatType });
}
/**
* Trigger AI voice synthesis. Server may return an empty msgInfo while
* the render is in-flight; we retry until a node materialises or the
* cap is hit. napcat uses the same 30-retry budget.
*
* The returned node plugs directly into
* `apis.groupFile.getPttUrl`, which already handles every other
* download-URL fetch in SnowLuma. The synthesis response does not contain a
* reliable message sequence; callers that need one must use `sendAiVoice`,
* which correlates the subsequent self-message receipt.
*/
async fetchAiVoice(
groupId: number,
voiceId: string,
text: string,
chatType: AiVoiceChatType | number,
maxRetries = 30,
): Promise<MediaIndexNode> {
// Random 32-bit session id — server uses this to deduplicate polls.
const sessionId = Math.floor(Math.random() * 0xffffffff) >>> 0;
for (let i = 0; i < maxRetries; i++) {
const node = await FetchAiVoice.invoke(this.ctx, {
groupId,
voiceId,
text,
chatType,
sessionId,
});
if (node) {
log.debug(
'AI voice synthesis completed group=%d media=%s polls=%d',
groupId,
describeAiVoiceFingerprint(node),
i + 1,
);
return node as MediaIndexNode;
}
}
throw new Error(`AI voice synthesis did not complete after ${maxRetries} polls`);
}
/**
* Publish an AI voice and wait for QQ's canonical self-message receipt.
*
* The 0x929B completion response carries the media node but, on live QQ,
* omits the group message sequence. Subscribe before publishing so an echo
* that wins the response race is buffered, then correlate by the exact media
* UUID. Never guess from content hashes or "the next voice in the group":
* concurrent requests can legitimately render byte-identical audio.
*/
async sendAiVoice(
groupId: number,
voiceId: string,
text: string,
chatType: AiVoiceChatType | number,
receiptTimeoutMs = AI_VOICE_RECEIPT_TIMEOUT_MS,
): Promise<AiVoiceSendReceipt> {
const candidates: GroupMessageEvent[] = [];
let candidatesOverflowed = false;
let expectedNode: MediaIndexNode | null = null;
let receiptSettled = false;
let resolveReceipt: ((event: GroupMessageEvent) => void) | null = null;
const receiptPromise = new Promise<GroupMessageEvent>((resolve) => {
resolveReceipt = resolve;
});
const claim = (event: GroupMessageEvent): boolean => {
if (receiptSettled) {
return false;
}
const key = `${event.groupId}:${event.msgSeq}:${event.msgId}`;
if (this.claimedAiVoiceReceiptKeys.has(key)) {
return false;
}
this.claimedAiVoiceReceiptKeys.add(key);
if (this.claimedAiVoiceReceiptKeys.size > AI_VOICE_CLAIM_LIMIT) {
const oldest = this.claimedAiVoiceReceiptKeys.values().next().value;
if (oldest !== undefined) {
this.claimedAiVoiceReceiptKeys.delete(oldest);
}
}
receiptSettled = true;
return true;
};
const accept = (event: GroupMessageEvent): void => {
if (event.groupId !== groupId || event.senderUin !== event.selfUin) {
return;
}
if (!event.elements.some((element) => element.type === 'record')) {
return;
}
if (expectedNode && matchesAiVoiceEvent(expectedNode, event) && claim(event)) {
resolveReceipt?.(event);
return;
}
candidates.push(event);
if (candidates.length > AI_VOICE_CANDIDATE_LIMIT) {
candidates.shift();
candidatesOverflowed = true;
}
};
const unsubscribe = this.ctx.events.on('group_message', accept);
let timeout: ReturnType<typeof setTimeout> | null = null;
try {
const node = await this.fetchAiVoice(groupId, voiceId, text, chatType);
expectedNode = node;
const fingerprint = describeAiVoiceFingerprint(node);
if (fingerprint === 'missing') {
throw new Error(
'AI voice was published but QQ returned no exact media identifier for receipt correlation',
);
}
const buffered = candidates.find(
(event) => matchesAiVoiceEvent(node, event) && claim(event),
);
if (!buffered && candidatesOverflowed) {
throw new Error(
`AI voice was published but receipt correlation overflowed after ${AI_VOICE_CANDIDATE_LIMIT} ` +
`unmatched self voice messages (group=${groupId} media=${fingerprint})`,
);
}
const event =
buffered ??
(await Promise.race([
receiptPromise,
new Promise<never>((_resolve, reject) => {
timeout = setTimeout(() => {
reject(
new Error(
`AI voice was published but no matching group-message receipt arrived within ${receiptTimeoutMs}ms ` +
`(group=${groupId} media=${fingerprint})`,
),
);
}, receiptTimeoutMs);
timeout.unref();
}),
]));
if (!Number.isInteger(event.msgSeq) || event.msgSeq <= 0) {
throw new Error(
`AI voice receipt carried invalid message sequence: ${event.msgSeq}`,
);
}
log.debug(
'AI voice receipt matched group=%d media=%s sequence=%d random=%d',
groupId,
fingerprint,
event.msgSeq,
event.msgId,
);
return {
sequence: event.msgSeq,
};
} finally {
if (timeout) {
clearTimeout(timeout);
}
unsubscribe();
}
}
// ─────────────── Voice-to-text (pttTrans.Trans{C2C,Group}PttReq) ───────────────
/**
* Send ONE `pttTrans.Trans{C2C,Group}PttReq` to trigger transcription and
* return the recognised text IF the response carries it inline (the
* already-transcribed case). For a freshly-received voice the response is an
* empty ack and the text is delivered later via the async Event 0x210
* subType-61 push — callers should treat `''` as "pending" and wait for the
* `ptt_trans_result` event (correlated by msgId).
*/
async translatePttToText(input: PttTransInput): Promise<string> {
// QQ NT sends the md5 as a 32-char lowercase HEX STRING, not the raw 16
// bytes (RE'd from wrapper.linux.node EncodeTroopPtt/EncodeC2CPtt: the md5
// field goes through a bytes→hex helper). Sending raw bytes → errCode -1079.
const md5 = input.md5Hex.toLowerCase();
const req: PttTransReq = input.isGroup
? {
type: 1,
groupItem: {
msgId: BigInt(input.msgId),
senderUin: BigInt(input.senderUin),
groupUin: BigInt(input.peerUin),
fileId: input.fileId ?? 0,
md5,
duration: input.duration,
size: input.size,
format: input.format,
uuid: input.uuid,
},
}
: {
type: 2,
c2cItem: {
msgId: BigInt(input.msgId),
senderUin: BigInt(input.senderUin),
receiverUin: BigInt(input.peerUin),
uuid: input.uuid,
duration: input.duration,
size: input.size,
format: input.format,
md5,
},
};
const cmd = input.isGroup ? 'pttTrans.TransGroupPttReq' : 'pttTrans.TransC2CPttReq';
const result = await this.ctx.sendRawPacket(cmd, protobuf_encode<PttTransReq>(req));
if (!result.success || !result.gotResponse || !result.responseData) {
throw new Error(result.errorMessage || 'ptt translate request failed');
}
const resp = protobuf_decode<PttTransResp>(result.responseData);
const item = input.isGroup ? resp?.groupResult : resp?.c2cResult;
if (item?.errCode) {
// Diagnostic (#165 备选): correlate QQ's errCode with the request fields we
// sourced from cache, to tell a server-side ASR failure apart from our own
// missing/zero field (md5/format/uuid). Behaviour unchanged — still throws.
log.warn(
'ptt translate errCode=%d isGroup=%s md5=%s format=%d uuid=%s dur=%d size=%d',
item.errCode,
input.isGroup,
input.md5Hex ? `${input.md5Hex.length / 2}B` : 'EMPTY',
input.format,
input.uuid ? 'set' : 'EMPTY',
input.duration,
input.size,
);
throw new Error(`ptt translate failed: error=${item.errCode}`);
}
return item?.text ?? ''; // '' = transcribing async; caller awaits the push
}
/**
* Transcode a voice record (`get_record out_format`, #165). `source` is a
* URL / local path / `base64://…` (the record's download URL in practice);
* it is loaded then transcoded to `format` via the bundled SILK-capable
* ffmpeg addon. Returns base64 + size. Throws on unsupported format or a
* failed conversion.
*/
async convertRecord(source: string, format: string): Promise<{ base64: string; size: number }> {
// Voices are tiny — cap the download well below loadBinarySource's 1 GiB
// default so a tampered/oversized source can't be pulled in.
const { bytes } = await loadBinarySource(source, 'record', 64 * 1024 * 1024);
return convertAudioBytes(bytes, format);
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,69 @@
import { createLogger } from '@snowluma/common/logger';
import type { ForwardNodePayload } from '@snowluma/protocol/events';
const MAX_CACHED_FORWARDS = 512;
const log = createLogger('Bridge.Forward');
type ForwardLayers = Map<string, ForwardNodePayload[]>;
interface ForwardCacheHit {
rootId: string;
resId: string;
layers: ForwardLayers;
nodes: ForwardNodePayload[];
}
/** Nested results share the lifetime and recency of the result that supplied them. */
export class ForwardResultCache {
private readonly entries = new Map<string, ForwardLayers>();
get(resId: string): ForwardCacheHit | undefined {
let hit: ForwardCacheHit | undefined;
// A nested result can be supplied by several retained outer results.
for (const [rootId, layers] of this.entries) {
const nodes = layers.get(resId);
if (nodes) {
hit = { rootId, resId, layers, nodes };
}
}
if (hit) {
this.entries.delete(hit.rootId);
this.entries.set(hit.rootId, hit.layers);
}
return hit;
}
set(rootId: string, layers: ReadonlyMap<string, ForwardNodePayload[]>): void {
if (!layers.has(rootId)) {
throw new Error('forward cache entry is missing its outer result');
}
this.entries.delete(rootId);
this.entries.set(rootId, new Map(layers));
while (this.entries.size > MAX_CACHED_FORWARDS) {
const oldest = this.entries.entries().next().value!;
this.entries.delete(oldest[0]);
log.trace(
'forward cache evicted: res_id=%s layers=%d remaining=%d',
oldest[0],
oldest[1].size,
this.entries.size,
);
}
}
update(hit: ForwardCacheHit, nodes: ForwardNodePayload[]): void {
// Enrichment may finish after eviction or replacement. It must not revive
// an old result or turn a nested result into an independent cache entry.
if (
this.entries.get(hit.rootId) === hit.layers &&
hit.layers.get(hit.resId) === hit.nodes
) {
hit.layers.set(hit.resId, nodes);
}
}
}
Loaded 100 of 1367 files, more files were not shown because too many files have changed in this diff. Show more