wowdump 0.3.9 → 0.3.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  import { createInterface } from "node:readline";
2
2
  import { stdin, stdout } from "node:process";
3
+ import { dumpNative } from "./dump.js";
3
4
  export const DEFAULT_IDLE_TIMEOUT_MS = 20 * 60 * 1000;
4
5
  export const DEFAULT_RIGHTS = Object.freeze([
5
6
  "PROCESS_QUERY_INFORMATION",
@@ -16,25 +17,25 @@ const MAX_QUERY_REGIONS = 100_000;
16
17
  function hexAddress(value) {
17
18
  const normalized = value.trim();
18
19
  if (!/^0x[0-9a-f]+$/i.test(normalized)) {
19
- throw new ReaderProtocolError("INVALID_ADDRESS", "address must be a hexadecimal 0x-prefixed string");
20
+ throw new MemoryProtocolError("INVALID_ADDRESS", "address must be a hexadecimal 0x-prefixed string");
20
21
  }
21
22
  const address = BigInt(normalized);
22
23
  if (address < 0n || address > 0xffffffffffffffffn) {
23
- throw new ReaderProtocolError("INVALID_ADDRESS", "address is outside the 64-bit range");
24
+ throw new MemoryProtocolError("INVALID_ADDRESS", "address is outside the 64-bit range");
24
25
  }
25
26
  return address;
26
27
  }
27
28
  function boundedSize(value, name, maximum = 16 * 1024 * 1024) {
28
29
  const size = Number(value);
29
30
  if (!Number.isSafeInteger(size) || size <= 0 || size > maximum) {
30
- throw new ReaderProtocolError("INVALID_SIZE", `${name} must be an integer between 1 and ${maximum}`);
31
+ throw new MemoryProtocolError("INVALID_SIZE", `${name} must be an integer between 1 and ${maximum}`);
31
32
  }
32
33
  return size;
33
34
  }
34
35
  function boundedPid(value) {
35
36
  const pid = Number(value);
36
37
  if (!Number.isSafeInteger(pid) || pid <= 0 || pid > 0x7fffffff) {
37
- throw new ReaderProtocolError("INVALID_PID", "pid must be a positive Windows process id");
38
+ throw new MemoryProtocolError("INVALID_PID", "pid must be a positive Windows process id");
38
39
  }
39
40
  return pid;
40
41
  }
@@ -43,7 +44,7 @@ function boundedCount(value, name, maximum, fallback) {
43
44
  return fallback;
44
45
  const count = Number(value);
45
46
  if (!Number.isSafeInteger(count) || count < 1 || count > maximum) {
46
- throw new ReaderProtocolError("INVALID_COUNT", `${name} must be an integer between 1 and ${maximum}`);
47
+ throw new MemoryProtocolError("INVALID_COUNT", `${name} must be an integer between 1 and ${maximum}`);
47
48
  }
48
49
  return count;
49
50
  }
@@ -52,20 +53,20 @@ function toHex(bytes) {
52
53
  }
53
54
  function bytesFromResult(result) {
54
55
  if (!result || !(result.bytes instanceof Uint8Array)) {
55
- throw new ReaderProtocolError("NATIVE_RESULT", "native reader returned no byte buffer");
56
+ throw new MemoryProtocolError("NATIVE_RESULT", "native memory returned no byte buffer");
56
57
  }
57
58
  const bytesRead = result.bytesRead === undefined ? result.bytes.byteLength : Number(result.bytesRead);
58
59
  if (!Number.isSafeInteger(bytesRead) || bytesRead < 0 || bytesRead > result.bytes.byteLength) {
59
- throw new ReaderProtocolError("NATIVE_RESULT", "native reader returned an invalid bytesRead value");
60
+ throw new MemoryProtocolError("NATIVE_RESULT", "native memory returned an invalid bytesRead value");
60
61
  }
61
62
  return { bytes: result.bytes.subarray(0, bytesRead), bytesRead };
62
63
  }
63
- export class ReaderProtocolError extends Error {
64
+ export class MemoryProtocolError extends Error {
64
65
  code;
65
66
  details;
66
67
  constructor(code, message, details) {
67
68
  super(message);
68
- this.name = "ReaderProtocolError";
69
+ this.name = "MemoryProtocolError";
69
70
  this.code = code;
70
71
  this.details = details;
71
72
  }
@@ -74,9 +75,9 @@ export class ReaderProtocolError extends Error {
74
75
  * Placeholder backend used until the platform adapter is installed. Keeping
75
76
  * this explicit makes an unavailable native layer observable to the caller.
76
77
  */
77
- export class UnavailableNativeReader {
78
+ export class UnavailableNativeMemory {
78
79
  diagnostic() {
79
- throw new ReaderProtocolError("NATIVE_BACKEND_UNAVAILABLE", "Windows native reader backend is not installed", {
80
+ throw new MemoryProtocolError("NATIVE_BACKEND_UNAVAILABLE", "Windows native memory backend is not installed", {
80
81
  platform: process.platform,
81
82
  requestedRights: [...DEFAULT_RIGHTS],
82
83
  elevation: this.elevationStatus()
@@ -97,23 +98,22 @@ export class UnavailableNativeReader {
97
98
  };
98
99
  }
99
100
  }
100
- export class ReaderBroker {
101
+ export class MemoryBroker {
101
102
  idleTimeoutMs;
102
103
  backend;
103
104
  now;
104
105
  onShutdown;
105
- runDebug;
106
106
  handles = new Map();
107
107
  watches = new Map();
108
108
  idleTimer;
109
109
  watchCounter = 0;
110
110
  stopped = false;
111
+ activeRequests = 0;
111
112
  constructor(options = {}) {
112
- this.backend = options.backend ?? new UnavailableNativeReader();
113
+ this.backend = options.backend ?? new UnavailableNativeMemory();
113
114
  this.idleTimeoutMs = Math.max(1, Math.floor(options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS));
114
115
  this.now = options.now ?? Date.now;
115
116
  this.onShutdown = options.onShutdown;
116
- this.runDebug = options.runDebug;
117
117
  this.armIdleTimer();
118
118
  }
119
119
  get isStopped() {
@@ -130,17 +130,24 @@ export class ReaderBroker {
130
130
  const command = typeof request?.command === "string" ? request.command : "unknown";
131
131
  const base = { id: request?.id, ok: true, command, timestamp: this.now() };
132
132
  if (this.stopped) {
133
- return { ...base, ok: false, error: { code: "BROKER_STOPPED", message: "reader broker is stopped" } };
133
+ return { ...base, ok: false, error: { code: "BROKER_STOPPED", message: "memory broker is stopped" } };
134
134
  }
135
+ this.activeRequests += 1;
136
+ if (this.idleTimer)
137
+ clearTimeout(this.idleTimer);
135
138
  try {
136
139
  const result = await this.dispatch(request);
137
140
  this.armIdleTimer();
138
- return { ...base, ...result, ok: true };
141
+ return { ...base, ...result, ok: result.ok !== false };
139
142
  }
140
143
  catch (error) {
141
144
  this.armIdleTimer();
142
145
  return { ...base, ok: false, error: serializeError(error, this.backend) };
143
146
  }
147
+ finally {
148
+ this.activeRequests -= 1;
149
+ this.armIdleTimer();
150
+ }
144
151
  }
145
152
  async shutdown(reason = "request") {
146
153
  if (this.stopped)
@@ -160,7 +167,7 @@ export class ReaderBroker {
160
167
  await Promise.resolve(this.onShutdown?.(reason)).catch(() => undefined);
161
168
  }
162
169
  armIdleTimer() {
163
- if (this.stopped)
170
+ if (this.stopped || this.activeRequests > 0)
164
171
  return;
165
172
  if (this.idleTimer)
166
173
  clearTimeout(this.idleTimer);
@@ -174,16 +181,16 @@ export class ReaderBroker {
174
181
  switch (request.command) {
175
182
  case "status":
176
183
  return {
184
+ engine: "native-memory",
185
+ capabilities: ["read", "regions", "modules", "dump", "watch"],
177
186
  pid: process.pid,
178
187
  idleTimeoutMs: this.idleTimeoutMs,
179
188
  activeHandles: this.handles.size,
180
189
  activeWatches: this.activeWatchCount,
181
190
  elevation: await Promise.resolve(this.backend.elevationStatus?.() ?? { state: "unknown" })
182
191
  };
183
- case "debug":
184
- if (!this.runDebug)
185
- throw new ReaderProtocolError("CDB_NOT_CONFIGURED", "elevated CDB runner is not configured");
186
- return this.runDebug(request);
192
+ case "dump":
193
+ return dumpNative(request, this.backend);
187
194
  case "modules":
188
195
  return this.modules(request);
189
196
  case "regions":
@@ -204,7 +211,7 @@ export class ReaderBroker {
204
211
  await this.shutdown("request");
205
212
  return { stopped: true };
206
213
  default:
207
- throw new ReaderProtocolError("UNKNOWN_COMMAND", `unsupported reader command: ${String(request?.command)}`);
214
+ throw new MemoryProtocolError("UNKNOWN_COMMAND", `unsupported memory command: ${String(request?.command)}`);
208
215
  }
209
216
  }
210
217
  async getHandle(pid, requested) {
@@ -212,7 +219,7 @@ export class ReaderBroker {
212
219
  if (existing && (!requested || requested === existing.id))
213
220
  return existing;
214
221
  if (requested && existing?.id !== requested) {
215
- throw new ReaderProtocolError("HANDLE_MISMATCH", `handle ${requested} is not open for pid ${pid}`);
222
+ throw new MemoryProtocolError("HANDLE_MISMATCH", `handle ${requested} is not open for pid ${pid}`);
216
223
  }
217
224
  const handle = await this.backend.openProcess(pid, DEFAULT_RIGHTS);
218
225
  this.handles.set(pid, handle);
@@ -270,13 +277,13 @@ export class ReaderBroker {
270
277
  async modules(request) {
271
278
  const pid = boundedPid(request.pid);
272
279
  if (!this.backend.enumerateModules) {
273
- throw new ReaderProtocolError("MODULE_ENUM_UNAVAILABLE", "native module enumeration is not installed");
280
+ throw new MemoryProtocolError("MODULE_ENUM_UNAVAILABLE", "native module enumeration is not installed");
274
281
  }
275
282
  return { pid, modules: await this.backend.enumerateModules(pid) };
276
283
  }
277
284
  async regions(request) {
278
285
  if (!this.backend.virtualQueryEx) {
279
- throw new ReaderProtocolError("VIRTUAL_QUERY_UNAVAILABLE", "native VirtualQueryEx backend is not installed");
286
+ throw new MemoryProtocolError("VIRTUAL_QUERY_UNAVAILABLE", "native VirtualQueryEx backend is not installed");
280
287
  }
281
288
  const pid = boundedPid(request.pid);
282
289
  const requestedStart = hexAddress(request.start ?? `0x${DEFAULT_QUERY_START.toString(16)}`);
@@ -284,7 +291,7 @@ export class ReaderBroker {
284
291
  const requestedEnd = hexAddress(request.end ?? `0x${DEFAULT_QUERY_END.toString(16)}`);
285
292
  const end = requestedEnd > MAX_USER_QUERY_ADDRESS ? MAX_USER_QUERY_ADDRESS : requestedEnd;
286
293
  if (end < start)
287
- throw new ReaderProtocolError("INVALID_RANGE", "end must be greater than or equal to start");
294
+ throw new MemoryProtocolError("INVALID_RANGE", "end must be greater than or equal to start");
288
295
  const maxRegions = boundedCount(request.maxRegions, "maxRegions", MAX_QUERY_REGIONS, 4096);
289
296
  const includeFree = request.includeFree === true;
290
297
  const handle = await this.getHandle(pid, request.handle);
@@ -299,13 +306,13 @@ export class ReaderBroker {
299
306
  regionSize = BigInt(String(raw.regionSize ?? "0"));
300
307
  }
301
308
  catch {
302
- throw new ReaderProtocolError("NATIVE_RESULT", "VirtualQueryEx returned an invalid region size");
309
+ throw new MemoryProtocolError("NATIVE_RESULT", "VirtualQueryEx returned an invalid region size");
303
310
  }
304
311
  if (regionSize <= 0n)
305
- throw new ReaderProtocolError("NATIVE_RESULT", "VirtualQueryEx returned a non-positive region size");
312
+ throw new MemoryProtocolError("NATIVE_RESULT", "VirtualQueryEx returned a non-positive region size");
306
313
  const next = baseAddress + regionSize;
307
314
  if (next <= cursor)
308
- throw new ReaderProtocolError("NATIVE_RESULT", "VirtualQueryEx did not advance the query address");
315
+ throw new MemoryProtocolError("NATIVE_RESULT", "VirtualQueryEx did not advance the query address");
309
316
  const state = Number(raw.state);
310
317
  if (includeFree || state === MEM_COMMIT) {
311
318
  regions.push({
@@ -359,7 +366,7 @@ export class ReaderBroker {
359
366
  }
360
367
  async watchPoll(request) {
361
368
  if (!request.watchId || typeof request.watchId !== "string")
362
- throw new ReaderProtocolError("INVALID_WATCH", "watchId is required");
369
+ throw new MemoryProtocolError("INVALID_WATCH", "watchId is required");
363
370
  const watch = this.watches.get(request.watchId);
364
371
  if (!watch || watch.stopped)
365
372
  return { watchId: request.watchId, stopped: true, events: [] };
@@ -400,12 +407,12 @@ export class ReaderBroker {
400
407
  }
401
408
  catch (error) {
402
409
  watch.stopped = true;
403
- return { watchId: watch.id, stopped: true, reason: "reader_error", events: [{ type: "reader_error", watchId: watch.id, pid: watch.pid, sequence: watch.sequence + 1, timestamp: this.now(), error: serializeError(error, this.backend) }] };
410
+ return { watchId: watch.id, stopped: true, reason: "memory_error", events: [{ type: "memory_error", watchId: watch.id, pid: watch.pid, sequence: watch.sequence + 1, timestamp: this.now(), error: serializeError(error, this.backend) }] };
404
411
  }
405
412
  }
406
413
  async watchStop(request) {
407
414
  if (!request.watchId || typeof request.watchId !== "string")
408
- throw new ReaderProtocolError("INVALID_WATCH", "watchId is required");
415
+ throw new MemoryProtocolError("INVALID_WATCH", "watchId is required");
409
416
  const watch = this.watches.get(request.watchId);
410
417
  if (!watch)
411
418
  return { watchId: request.watchId, stopped: false };
@@ -423,21 +430,21 @@ function equalBytes(left, right) {
423
430
  return true;
424
431
  }
425
432
  function serializeError(error, backend) {
426
- if (error instanceof ReaderProtocolError) {
433
+ if (error instanceof MemoryProtocolError) {
427
434
  return { code: error.code, message: error.message, ...(error.details ? { details: error.details } : {}) };
428
435
  }
429
436
  const message = error instanceof Error ? error.message : String(error);
430
437
  const typed = error;
431
438
  return {
432
- code: typeof typed.code === "string" ? typed.code : "READER_ERROR",
439
+ code: typeof typed.code === "string" ? typed.code : "MEMORY_ERROR",
433
440
  message,
434
441
  ...(typed.details && typeof typed.details === "object" ? { details: typed.details } : {}),
435
442
  elevation: backend.elevationStatus ? backend.elevationStatus() : { state: "unknown" }
436
443
  };
437
444
  }
438
445
  /** Start a JSONL broker process over stdio. */
439
- export function serveReaderBroker(options = {}) {
440
- const broker = new ReaderBroker({ ...options, attachStdio: false });
446
+ export function serveMemoryBroker(options = {}) {
447
+ const broker = new MemoryBroker({ ...options, attachStdio: false });
441
448
  if (options.attachStdio !== false) {
442
449
  const input = options.input ?? stdin;
443
450
  const output = options.output ?? stdout;
@@ -475,7 +482,7 @@ export function serveReaderBroker(options = {}) {
475
482
  return broker;
476
483
  }
477
484
  /** Small typed client used by the CLI; transport owns process/UAC reuse. */
478
- export class ReaderClient {
485
+ export class MemoryClient {
479
486
  transport;
480
487
  requestTimeoutMs;
481
488
  counter = 0;
@@ -488,7 +495,7 @@ export class ReaderClient {
488
495
  const line = await withTimeout(this.transport.request(JSON.stringify({ ...request, id })), this.requestTimeoutMs);
489
496
  const response = JSON.parse(line);
490
497
  if (!response || typeof response !== "object" || typeof response.ok !== "boolean") {
491
- throw new ReaderProtocolError("INVALID_RESPONSE", "reader broker returned an invalid JSONL response");
498
+ throw new MemoryProtocolError("INVALID_RESPONSE", "memory broker returned an invalid JSONL response");
492
499
  }
493
500
  return response;
494
501
  }
@@ -498,8 +505,8 @@ export class ReaderClient {
498
505
  }
499
506
  function withTimeout(promise, timeoutMs) {
500
507
  return new Promise((resolve, reject) => {
501
- const timer = setTimeout(() => reject(new ReaderProtocolError("REQUEST_TIMEOUT", `reader request timed out after ${timeoutMs}ms`)), timeoutMs);
502
- timer.unref?.();
508
+ const timer = setTimeout(() => reject(new MemoryProtocolError("REQUEST_TIMEOUT", `memory request timed out after ${timeoutMs}ms`)), timeoutMs);
509
+ // An in-flight request must settle even when the transport has no active handles.
503
510
  promise.then(value => { clearTimeout(timer); resolve(value); }, error => { clearTimeout(timer); reject(error); });
504
511
  });
505
512
  }
@@ -509,7 +516,7 @@ export function createElevatedLaunchSpec(file, args = []) {
509
516
  return Object.freeze({ file, args: [...args], verb: "runas", windowsHide: false });
510
517
  }
511
518
  /** Reuse one broker client and invoke the launcher only after the first miss. */
512
- export class ReaderBrokerPool {
519
+ export class MemoryBrokerPool {
513
520
  client;
514
521
  launching;
515
522
  launcher;
@@ -541,10 +548,10 @@ export class ReaderBrokerPool {
541
548
  return this.client;
542
549
  if (!this.launching) {
543
550
  this.launching = this.launcher.launch(this.launchSpec).then(result => {
544
- this.client = new ReaderClient({ transport: result.transport, requestTimeoutMs: this.requestTimeoutMs });
551
+ this.client = new MemoryClient({ transport: result.transport, requestTimeoutMs: this.requestTimeoutMs });
545
552
  return this.client;
546
553
  }).catch(error => {
547
- throw new ReaderProtocolError("ELEVATION_FAILED", error instanceof Error ? error.message : String(error), {
554
+ throw new MemoryProtocolError("ELEVATION_FAILED", error instanceof Error ? error.message : String(error), {
548
555
  verb: this.launchSpec.verb,
549
556
  requestedRights: [...DEFAULT_RIGHTS],
550
557
  elevation: "launch rejected or cancelled"
@@ -0,0 +1 @@
1
+ export { MemoryClient, MemoryBrokerPool, MemoryProtocolError, createElevatedLaunchSpec } from "./broker.js";
@@ -0,0 +1,142 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, open, rm, writeFile } from "node:fs/promises";
3
+ import { join, resolve } from "node:path";
4
+ import { readPeLayout } from "../analysis/pe.js";
5
+ import { sha256File } from "../core/build-store.js";
6
+ function fail(code, message) { throw Object.assign(new Error(message), { code }); }
7
+ function bounded(n, max) {
8
+ if (!Number.isSafeInteger(n) || n < 1 || n > max)
9
+ fail("DUMP_ARGUMENT_INVALID", "invalid dump limit");
10
+ return n;
11
+ }
12
+ /** Only the broker touches bytes. IPC carries paths and bounded summaries. */
13
+ export async function dumpNative(request, backend) {
14
+ const chunkBytes = bounded(request.chunkBytes ?? 65536, 1024 * 1024);
15
+ bounded(request.maxSectionBytes, 512 * 1024 * 1024);
16
+ bounded(request.maxTotalBytes, 768 * 1024 * 1024);
17
+ bounded(request.timeoutMs, 15 * 60 * 1000);
18
+ bounded(request.pid, 0x7fffffff);
19
+ if (!/^[A-Za-z0-9@._-]+$/.test(request.buildKey) || !/^[a-f0-9]{64}$/i.test(request.executableSha256))
20
+ fail("DUMP_ARGUMENT_INVALID", "invalid build identity");
21
+ if (!backend.virtualQueryEx || !backend.enumerateModules)
22
+ fail("NATIVE_BACKEND_UNAVAILABLE", "module enumeration and page query are required");
23
+ const module = (await backend.enumerateModules(request.pid)).find(m => /^Wow\.exe$/i.test(m.name));
24
+ if (!module?.path)
25
+ fail("MODULE_NOT_FOUND", "Wow.exe not found");
26
+ if (await sha256File(module.path) !== request.executableSha256)
27
+ fail("BUILD_MISMATCH", "target executable hash differs from build");
28
+ const layout = await readPeLayout(module.path);
29
+ if (module.size !== undefined && module.size !== layout.moduleSize)
30
+ fail("BUILD_MISMATCH", "module size differs from PE");
31
+ if (!Array.isArray(request.sections) || request.sections.length < 1 || request.sections.length > 64 || new Set(request.sections).size !== request.sections.length)
32
+ fail("DUMP_ARGUMENT_INVALID", "invalid section selection");
33
+ const selected = request.sections.map(name => {
34
+ const s = layout.sections.find(s => s.name.toLowerCase() === name.toLowerCase());
35
+ if (!s || !/^\.[A-Za-z0-9_-]+$/.test(s.name))
36
+ fail("SECTION_NOT_FOUND", `unknown section ${name}`);
37
+ return s;
38
+ });
39
+ let total = 0;
40
+ for (const s of selected) {
41
+ const size = s.virtualSize || s.rawSize;
42
+ if (size > request.maxSectionBytes || (total += size) > request.maxTotalBytes)
43
+ fail("DUMP_LIMIT_EXCEEDED", "raise explicit limits or select fewer sections");
44
+ if (BigInt(s.rva) + BigInt(size) > BigInt(layout.moduleSize))
45
+ fail("PE_INVALID", "section exceeds image");
46
+ }
47
+ const directory = resolve(request.outputDir);
48
+ // Non-recursive exclusive directory creation prevents overwriting prior evidence.
49
+ await mkdir(resolve(directory, ".."), { recursive: true });
50
+ await mkdir(directory);
51
+ const startedAt = new Date().toISOString();
52
+ const deadline = Date.now() + request.timeoutMs;
53
+ const sections = [];
54
+ let completedBytes = 0;
55
+ let handle;
56
+ try {
57
+ handle = await backend.openProcess(request.pid, ["PROCESS_QUERY_INFORMATION", "PROCESS_VM_READ"]);
58
+ for (const section of selected) {
59
+ const size = section.virtualSize || section.rawSize;
60
+ const base = BigInt(module.base) + BigInt(section.rva);
61
+ const file = join(directory, `${section.name}.bin`);
62
+ const output = await open(file, "wx");
63
+ const hash = createHash("sha256");
64
+ const gaps = [];
65
+ let offset = 0, readSize = 0;
66
+ try {
67
+ while (offset < size) {
68
+ if (Date.now() >= deadline)
69
+ fail("DUMP_TIMEOUT", "native dump timed out");
70
+ if (backend.isProcessAlive && !await backend.isProcessAlive(request.pid))
71
+ fail("TARGET_EXITED", "target exited during dump");
72
+ const address = base + BigInt(offset);
73
+ let count = Math.min(chunkBytes, size - offset), bytes = Buffer.alloc(0), reason = "unreadable-page";
74
+ try {
75
+ const page = await backend.virtualQueryEx(handle, address);
76
+ const end = BigInt(String(page.baseAddress)) + BigInt(String(page.regionSize));
77
+ if (end <= address)
78
+ fail("PAGE_INVALID", "page query made no progress");
79
+ count = Number(BigInt(count) < end - address ? BigInt(count) : end - address);
80
+ const protection = Number(page.protect);
81
+ if (Number(page.state) === 0x1000 && !(protection & 0x100) && [2, 4, 8, 0x20, 0x40, 0x80].includes(protection & 0xff)) {
82
+ const read = await backend.readProcessMemory(handle, address, count);
83
+ const n = read.bytesRead ?? read.bytes.length;
84
+ if (!Number.isSafeInteger(n) || n < 0 || n > count || n > read.bytes.length)
85
+ fail("NATIVE_RESULT", "invalid read length");
86
+ bytes = Buffer.from(read.bytes.subarray(0, n));
87
+ reason = "short-read";
88
+ }
89
+ }
90
+ catch (error) {
91
+ if (error.code === "NATIVE_RESULT")
92
+ throw error;
93
+ count = Math.min(count, 4096 - Number(address % 4096n));
94
+ reason = error instanceof Error ? error.message.slice(0, 160) : "read-failed";
95
+ }
96
+ const block = Buffer.alloc(count);
97
+ bytes.copy(block);
98
+ readSize += bytes.length;
99
+ if (bytes.length < count) {
100
+ const gap = { offset: offset + bytes.length, size: count - bytes.length, reason };
101
+ const previous = gaps.at(-1);
102
+ if (previous && previous.reason === reason && previous.offset + previous.size === gap.offset)
103
+ previous.size += gap.size;
104
+ else
105
+ gaps.push(gap);
106
+ if (gaps.length > 65536)
107
+ fail("DUMP_GAP_LIMIT", "too many unreadable ranges");
108
+ }
109
+ let written = 0;
110
+ while (written < count) {
111
+ const r = await output.write(block, written, count - written, offset + written);
112
+ if (!r.bytesWritten)
113
+ fail("DUMP_WRITE_FAILED", "zero byte write");
114
+ written += r.bytesWritten;
115
+ }
116
+ hash.update(block);
117
+ offset += count;
118
+ completedBytes += count;
119
+ await writeFile(join(directory, "progress.json"), JSON.stringify({ completedBytes, totalBytes: total, section: section.name, offset }));
120
+ }
121
+ }
122
+ finally {
123
+ await output.close();
124
+ }
125
+ sections.push({ ...section, runtimeAddress: `0x${base.toString(16)}`, file, bytes: size, readSize, shortRead: readSize !== size, gaps, sha256: hash.digest("hex") });
126
+ }
127
+ if (Date.now() >= deadline)
128
+ fail("DUMP_TIMEOUT", "native dump timed out");
129
+ const complete = sections.every(s => !s.shortRead);
130
+ const manifest = { schema: "wowdump.runtime-dump.v4", engine: "native-memory", buildKey: request.buildKey, executableSha256: request.executableSha256, pid: request.pid, moduleBase: module.base, preferredImageBase: layout.imageBase, moduleSize: layout.moduleSize, startedAt, generatedAt: new Date().toISOString(), atomic: false, complete, zeroFilledGaps: true, sections, totalBytes: total };
131
+ const manifestFile = join(directory, "manifest.json");
132
+ await writeFile(manifestFile, JSON.stringify(manifest, null, 2));
133
+ return { ok: complete, kind: "dump", manifestFile, outputDirectory: directory, complete, totalBytes: total, sections: sections.map(s => ({ name: s.name, bytes: s.bytes, readSize: s.readSize, sha256: s.sha256, shortRead: s.shortRead })), ...(!complete ? { error: { code: "DUMP_INCOMPLETE", message: "manifest records unreadable ranges" } } : {}) };
134
+ }
135
+ catch (error) {
136
+ await rm(directory, { recursive: true, force: true });
137
+ throw error;
138
+ }
139
+ finally {
140
+ await handle?.close();
141
+ }
142
+ }
@@ -3,7 +3,7 @@ import { createConnection } from "node:net";
3
3
  import { mkdir, open, readFile, rm } from "node:fs/promises";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import koffi from "koffi";
6
- import { ReaderProtocolError } from "./broker.js";
6
+ import { MemoryProtocolError } from "./broker.js";
7
7
  function quoteWindowsArgument(value) {
8
8
  if (value.length > 0 && !/[\s"]/u.test(value))
9
9
  return value;
@@ -95,7 +95,7 @@ function requestPipe(metadata, request, timeoutMs) {
95
95
  resolveResult(value);
96
96
  };
97
97
  const timer = setTimeout(() => {
98
- const error = new Error(`reader broker request timed out after ${timeoutMs}ms`);
98
+ const error = new Error(`memory broker request timed out after ${timeoutMs}ms`);
99
99
  error.code = "BROKER_REQUEST_TIMEOUT";
100
100
  finish(error);
101
101
  }, timeoutMs);
@@ -115,7 +115,7 @@ function requestPipe(metadata, request, timeoutMs) {
115
115
  });
116
116
  socket.once("error", error => finish(error));
117
117
  socket.once("end", () => { if (!settled)
118
- finish(new Error("reader broker closed without a response")); });
118
+ finish(new Error("memory broker closed without a response")); });
119
119
  });
120
120
  }
121
121
  async function pause(milliseconds) {
@@ -125,12 +125,12 @@ export class WindowsBrokerManager {
125
125
  metadataFile;
126
126
  options;
127
127
  constructor(options) {
128
- this.options = { ...options, home: resolve(options.home), readerEntry: resolve(options.readerEntry) };
129
- this.metadataFile = join(this.options.home, "runtime", "reader-broker.json");
128
+ this.options = { ...options, home: resolve(options.home), memoryEntry: resolve(options.memoryEntry) };
129
+ this.metadataFile = join(this.options.home, "runtime", "memory-broker.json");
130
130
  }
131
131
  async request(invocation) {
132
132
  const request = invocationRequest(invocation);
133
- const requestTimeout = invocation.command === "debug"
133
+ const requestTimeout = invocation.command === "dump"
134
134
  ? Math.max(this.options.requestTimeoutMs ?? 30_000, Math.min(15 * 60_000, Number(invocation.payload.durationMs ?? invocation.payload.timeoutMs ?? 120_000) + 10_000))
135
135
  : this.options.requestTimeoutMs ?? 30_000;
136
136
  const existing = await this.readMetadata();
@@ -140,7 +140,7 @@ export class WindowsBrokerManager {
140
140
  }
141
141
  catch (error) {
142
142
  const code = error.code;
143
- // A long-running debugger request is not evidence that the broker died.
143
+ // A long-running dump request is not evidence that the broker died.
144
144
  // Keep its metadata so the next command reuses the elevated process.
145
145
  if (code === "BROKER_REQUEST_TIMEOUT")
146
146
  throw error;
@@ -153,7 +153,7 @@ export class WindowsBrokerManager {
153
153
  async readMetadata() {
154
154
  try {
155
155
  const value = JSON.parse(await readFile(this.metadataFile, "utf8"));
156
- return value?.schema === "wowdump.reader-broker.v1"
156
+ return value?.schema === "wowdump.memory-broker.v1"
157
157
  && value.host === "127.0.0.1"
158
158
  && Number.isSafeInteger(value.port)
159
159
  && value.port > 0
@@ -166,6 +166,32 @@ export class WindowsBrokerManager {
166
166
  return undefined;
167
167
  }
168
168
  }
169
+ async retireLegacyBroker() {
170
+ const file = join(this.options.home, "runtime", "reader-broker.json");
171
+ let metadata;
172
+ try {
173
+ metadata = JSON.parse(await readFile(file, "utf8"));
174
+ }
175
+ catch (error) {
176
+ if (error.code === "ENOENT")
177
+ return;
178
+ throw error;
179
+ }
180
+ if (metadata.schema !== "wowdump.reader-broker.v1" || metadata.host !== "127.0.0.1" ||
181
+ !Number.isSafeInteger(metadata.port) || metadata.port < 1 || metadata.port > 65535 || typeof metadata.token !== "string") {
182
+ throw new MemoryProtocolError("LEGACY_BROKER_INVALID", "legacy broker metadata is invalid");
183
+ }
184
+ try {
185
+ const result = await requestPipe(metadata, { command: "shutdown" }, 3000);
186
+ if (!result?.ok)
187
+ throw new MemoryProtocolError("LEGACY_BROKER_BUSY", "legacy broker did not accept shutdown");
188
+ }
189
+ catch (error) {
190
+ if (error.code !== "ECONNREFUSED")
191
+ throw error;
192
+ }
193
+ await rm(file, { force: true });
194
+ }
169
195
  async launchBroker() {
170
196
  await mkdir(dirname(this.metadataFile), { recursive: true });
171
197
  const lockFile = `${this.metadataFile}.lock`;
@@ -187,19 +213,20 @@ export class WindowsBrokerManager {
187
213
  return this.launchBroker();
188
214
  }
189
215
  const token = randomBytes(32).toString("hex");
190
- const args = [this.options.readerEntry, "--listen", "127.0.0.1", "--port", "0", "--token", token, "--metadata", this.metadataFile];
216
+ const args = [this.options.memoryEntry, "--listen", "127.0.0.1", "--port", "0", "--token", token, "--metadata", this.metadataFile];
191
217
  const launch = this.options.launch ?? shellExecuteRunAs;
192
218
  try {
219
+ await this.retireLegacyBroker();
193
220
  try {
194
- launch(this.options.nodeExecutable ?? process.execPath, args, dirname(this.options.readerEntry));
221
+ launch(this.options.nodeExecutable ?? process.execPath, args, dirname(this.options.memoryEntry));
195
222
  }
196
223
  catch (error) {
197
224
  const native = error;
198
- throw new ReaderProtocolError("ELEVATION_FAILED", native.message, {
225
+ throw new MemoryProtocolError("ELEVATION_FAILED", native.message, {
199
226
  verb: "runas",
200
227
  win32: native.win32 ?? null,
201
228
  requestedRights: ["PROCESS_QUERY_INFORMATION", "PROCESS_VM_READ"],
202
- readerEntry: this.options.readerEntry
229
+ memoryEntry: this.options.memoryEntry
203
230
  });
204
231
  }
205
232
  const deadline = Date.now() + (this.options.launchTimeoutMs ?? 30_000);
@@ -209,7 +236,7 @@ export class WindowsBrokerManager {
209
236
  return metadata;
210
237
  await pause(100);
211
238
  }
212
- throw new Error(`elevated reader broker did not become ready within ${this.options.launchTimeoutMs ?? 30_000}ms`);
239
+ throw new Error(`elevated memory broker did not become ready within ${this.options.launchTimeoutMs ?? 30_000}ms`);
213
240
  }
214
241
  finally {
215
242
  await lock.close().catch(() => undefined);
@@ -2,9 +2,8 @@
2
2
  import { createServer } from "node:net";
3
3
  import { mkdir, rm, writeFile } from "node:fs/promises";
4
4
  import { dirname } from "node:path";
5
- import { serveReaderBroker } from "./broker.js";
6
- import { executeCdbDebug } from "../debug/cdb.js";
7
- import { createWindowsNativeReader } from "./windows.js";
5
+ import { serveMemoryBroker } from "./broker.js";
6
+ import { createWindowsNativeMemory } from "./windows.js";
8
7
  function argument(name) {
9
8
  const index = process.argv.indexOf(name);
10
9
  return index >= 0 ? process.argv[index + 1] : undefined;
@@ -14,24 +13,10 @@ const port = Number(argument("--port") ?? "0");
14
13
  const token = argument("--token");
15
14
  const metadata = argument("--metadata");
16
15
  let server;
17
- const backend = process.env.WOWDUMP_USE_FAKE_READER === "1" ? undefined : createWindowsNativeReader();
18
- const broker = serveReaderBroker({
16
+ const backend = process.env.WOWDUMP_USE_FAKE_MEMORY === "1" ? undefined : createWindowsNativeMemory();
17
+ const broker = serveMemoryBroker({
19
18
  attachStdio: !listen,
20
19
  backend,
21
- runDebug: async (request) => {
22
- const debugRequest = request;
23
- if (!backend)
24
- return executeCdbDebug(debugRequest);
25
- const handle = await backend.openProcess(request.pid, ["PROCESS_QUERY_INFORMATION"]);
26
- try {
27
- return await executeCdbDebug(debugRequest, address => backend.virtualQueryEx
28
- ? backend.virtualQueryEx(handle, address)
29
- : Promise.reject(new Error("VirtualQueryEx is not installed")));
30
- }
31
- finally {
32
- await Promise.resolve(handle.close()).catch(() => undefined);
33
- }
34
- },
35
20
  onShutdown: async () => {
36
21
  server?.close();
37
22
  if (metadata)
@@ -56,7 +41,7 @@ if (listen) {
56
41
  try {
57
42
  const envelope = JSON.parse(line);
58
43
  if (envelope.token !== token || !envelope.request) {
59
- socket.write(`${JSON.stringify({ ok: false, command: "auth", timestamp: Date.now(), error: { code: "AUTH_FAILED", message: "invalid reader broker token" } })}\n`);
44
+ socket.write(`${JSON.stringify({ ok: false, command: "auth", timestamp: Date.now(), error: { code: "AUTH_FAILED", message: "invalid memory broker token" } })}\n`);
60
45
  return;
61
46
  }
62
47
  socket.write(`${JSON.stringify(await broker.handle(envelope.request))}\n`);
@@ -72,8 +57,8 @@ if (listen) {
72
57
  await mkdir(dirname(metadata), { recursive: true });
73
58
  const address = server?.address();
74
59
  if (!address || typeof address === "string")
75
- throw new Error("reader broker did not acquire a TCP endpoint");
76
- await writeFile(metadata, `${JSON.stringify({ schema: "wowdump.reader-broker.v1", host: listen, port: address.port, token, pid: process.pid })}\n`, { encoding: "utf8", mode: 0o600 });
60
+ throw new Error("memory broker did not acquire a TCP endpoint");
61
+ await writeFile(metadata, `${JSON.stringify({ schema: "wowdump.memory-broker.v1", host: listen, port: address.port, token, pid: process.pid })}\n`, { encoding: "utf8", mode: 0o600 });
77
62
  });
78
63
  }
79
64
  const shutdown = () => {