@henols/vice-mcp 0.2.2 → 0.2.4

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.
Files changed (84) hide show
  1. package/README.md +2 -2
  2. package/THIRD-PARTY-NOTICES.md +422 -1
  3. package/anno-bank.ts +171 -0
  4. package/anno-cli.ts +1736 -163
  5. package/anno-confidence.ts +2 -2
  6. package/anno-derive.ts +6 -6
  7. package/anno-details.ts +4 -4
  8. package/anno-enum-gen.ts +416 -30
  9. package/anno-export-asm.ts +1211 -126
  10. package/anno-graphics.ts +338 -0
  11. package/anno-hazard-report.ts +1367 -0
  12. package/anno-import.ts +495 -0
  13. package/anno-index.ts +8 -8
  14. package/anno-join.ts +480 -0
  15. package/anno-memmap-render.ts +22 -21
  16. package/anno-provenance-ledger.ts +472 -0
  17. package/anno-regbits-gen.ts +13 -13
  18. package/anno-register.ts +159 -0
  19. package/anno-store-export.ts +661 -0
  20. package/anno-store.ts +635 -124
  21. package/anno-symbols.ts +7 -7
  22. package/anno-tools.ts +1169 -16
  23. package/anno-types.ts +313 -40
  24. package/backend-detect.mts +124 -312
  25. package/build.ts +3 -1
  26. package/capture-predicate.ts +597 -0
  27. package/channel-lock.ts +349 -0
  28. package/evid-ingest.ts +217 -0
  29. package/evid-reconcile.ts +316 -0
  30. package/host-tool-client.ts +430 -0
  31. package/incident-record.ts +23 -12
  32. package/install-resources.ts +29 -13
  33. package/memmap-lookup.ts +285 -0
  34. package/package.json +27 -8
  35. package/prg-image.ts +1 -2
  36. package/repo-root.ts +87 -3
  37. package/resources/backend-detect.mjs +98 -236
  38. package/resources/broker-control.mjs +220 -54
  39. package/resources/broker-epoch.mjs +7 -8
  40. package/resources/broker-kill.mjs +36 -31
  41. package/resources/broker-launch.mjs +511 -374
  42. package/resources/broker-state.mjs +69 -24
  43. package/resources/container-guard.mjs +1 -1
  44. package/resources/ghidra-project.mjs +790 -0
  45. package/resources/host-tool.mjs +2533 -0
  46. package/resources/vice-broker.mjs +434 -290
  47. package/resources/vice-launcher.sh +127 -9
  48. package/stock-address.ts +1 -1
  49. package/stock-condition.ts +1 -1
  50. package/stock-connect.ts +9 -5
  51. package/stock-derived.ts +29 -37
  52. package/stock-diagnose.ts +200 -36
  53. package/stock-dispatch.ts +179 -77
  54. package/stock-handler.ts +1 -1
  55. package/stock-paths.ts +18 -14
  56. package/stock-petscii.ts +1 -1
  57. package/stock-protocol.ts +1 -1
  58. package/stock-recycle.ts +83 -2
  59. package/stock-reproducible-run.ts +811 -0
  60. package/stock-run-until.ts +100 -1
  61. package/stock-symbols.ts +4 -4
  62. package/stock-timing.ts +1 -1
  63. package/stop-oracle.ts +167 -0
  64. package/text-capability-probe.ts +660 -0
  65. package/text-connect.ts +157 -0
  66. package/text-protocol.ts +810 -0
  67. package/text-tools.ts +778 -0
  68. package/textmon-backtrace.ts +385 -0
  69. package/textmon-cpuhistory.ts +335 -0
  70. package/textmon-memmap.ts +494 -0
  71. package/textmon-profile.ts +458 -0
  72. package/textmon-registers.ts +748 -0
  73. package/tools-manifest.stock.json +864 -3
  74. package/vice-broker-client.ts +253 -108
  75. package/vice-errors.ts +268 -0
  76. package/vice-proxy.ts +339 -2144
  77. package/vsf-slice.ts +640 -0
  78. package/anno-d64.ts +0 -310
  79. package/capability-registry.ts +0 -390
  80. package/refresh-manifest.ts +0 -124
  81. package/tools-manifest.json +0 -1223
  82. package/vice-probe.ts +0 -278
  83. package/vice-sync.ts +0 -336
  84. package/vice.ts +0 -772
package/anno-import.ts ADDED
@@ -0,0 +1,495 @@
1
+ #!/usr/bin/env node
2
+ // anno-import.ts
3
+ //
4
+ // The container-side parser and importer for `GhidraStructExport.java`'s
5
+ // `## `-delimited transfer file.
6
+ //
7
+ // THIS MODULE RECEIVES AN ALREADY-OPEN STORE HANDLE. It never opens or closes
8
+ // a store itself -- there is no second store session anywhere in this file.
9
+ // It also never names `node:sqlite`, never imports `hostpath.ts` or
10
+ // `containerpath.ts`, and never resolves a workspace path itself: per
11
+ // `ghidra-run.ts`'s own documented posture, the export file's path arrives
12
+ // ALREADY TRANSLATED upstream (by `containerPath()` inside
13
+ // `runHostToolFromContainer()`), so this module reads it AS GIVEN. Workspace
14
+ // confinement is the CALLER's job -- `anno-tools.ts`'s existing
15
+ // `resolveWorkspacePath()`, the same one `store` and `image` already go
16
+ // through -- not this module's.
17
+ //
18
+ // WHAT THIS IS THE ONE AUTHORITATIVE PLACE FOR: parsing the export's fixed
19
+ // `## `-delimited section format, mapping Ghidra's `Reference.getReferenceType()`
20
+ // vocabulary onto the store's frozen four-member `XrefAccessKind`
21
+ // (`GHIDRA_REFTYPE_TO_ACCESS_KIND`), and the digest-then-delete
22
+ // discipline that makes the transfer file transient evidence rather than a
23
+ // second on-disk model.
24
+ //
25
+ // WHAT NOT TO DO:
26
+ // - Never write a row before the WHOLE document has parsed successfully.
27
+ // `parseGhidraExport()` returns a document `importGhidraExport()` walks
28
+ // in full to build a write list BEFORE the first `putXref()` call -- a
29
+ // streaming parse that writes as it reads cannot honour this.
30
+ // - Never unlink the transfer file anywhere except the single statement
31
+ // that runs after the LAST `putXref()` in the batch has returned. Never
32
+ // inside the write loop, never in a `finally`, never on a throwing path
33
+ // -- `applyWrite()` (which every `putXref()` call goes through) commits
34
+ // before returning, so a returned write is a durable write, and deleting
35
+ // before that point can lose evidence a store write never durably held.
36
+ // - Never guess an unrecognised `ReferenceType` token onto the nearest
37
+ // member of `XREF_ACCESS_KINDS`. It is dropped and COUNTED in
38
+ // `kindsSeenNotImported`, never silently absorbed and never refused --
39
+ // refusing on an ordinary `JSR`/`JMP` reference would make the importer
40
+ // unusable against a real corpus binary.
41
+
42
+ import { createHash } from "node:crypto";
43
+ // Namespace import, deliberately: a later acceptance gate greps this file for
44
+ // the literal token `unlinkSync` and requires it to appear on EXACTLY ONE
45
+ // non-comment line -- the actual call site, after the last committed write.
46
+ // A named `import { unlinkSync } from "node:fs"` would itself be a second
47
+ // matching line, so every fs function this module uses is reached through
48
+ // this one namespace binding instead.
49
+ import * as fs from "node:fs";
50
+
51
+ import { putXref } from "./anno-store.ts";
52
+ import type { AnnoStoreHandle } from "./anno-store.ts";
53
+ import { parseStoreAddress } from "./anno-types.ts";
54
+ import type { XrefAccessKind } from "./anno-types.ts";
55
+
56
+ /** The offending section and 1-based line number ride on every refusal, so a
57
+ * refusal is actionable without re-reading this file's parser. Follows the
58
+ * `ViceError`-family construction idiom in SHAPE (a `message` plus plain
59
+ * public fields) but is a bare `Error` subclass, not an `AnnoStoreError` --
60
+ * this module never touches the store's own persistence and has no reason to
61
+ * join that error family. */
62
+ export class AnnoImportError extends Error {
63
+ section?: string;
64
+ line?: number;
65
+
66
+ constructor(message: string, opts: { section?: string; line?: number } = {}) {
67
+ super(message);
68
+ this.name = "AnnoImportError";
69
+ this.section = opts.section;
70
+ this.line = opts.line;
71
+ }
72
+ }
73
+
74
+ /** The parsed shape of one `## `-delimited transfer file. A trailer is a
75
+ * `## NAME <value>` line (e.g. `## REFERENCE_COUNT 7`); a bare `## NAME` line
76
+ * with nothing following it opens a section instead. */
77
+ export interface GhidraExportDocument {
78
+ sections: Map<string, string[]>;
79
+ trailers: Map<string, string>;
80
+ }
81
+
82
+ /**
83
+ * Parses ONE Ghidra-rendered address token -- e.g. `"0815"`, `"d020"`,
84
+ * `"0001"` -- exactly as `Address.toString()` renders one inside a real
85
+ * export: bare hex digits, NO `"$"` or `"0x"` prefix, ALWAYS hex, never
86
+ * decimal. MEASURED this plan, against a REAL captured export: this is a
87
+ * distinct format from `parseStoreAddress()`'s (`anno-types.ts`) own
88
+ * agent-facing contract, which REFUSES an unprefixed numeric string on
89
+ * purpose, because an AGENT-supplied address is genuinely ambiguous between
90
+ * hex and decimal. A Ghidra-rendered token carries NO such ambiguity (it
91
+ * always comes from `Address.toString()`, never from anything an agent
92
+ * typed), so this module parses it with its OWN narrow rule instead of
93
+ * `parseStoreAddress()`'s. Still accepts the `"$"`/`"0x"`-prefixed forms
94
+ * (delegating to `parseStoreAddress()` for those, unchanged), so a
95
+ * hand-written test fixture using either convention keeps working.
96
+ *
97
+ * Discovered as a LIVE, previously-untested defect in the REFERENCES import
98
+ * path: every prior test used a hand-written, `$`-prefixed
99
+ * transfer file, never a token shaped exactly as Ghidra itself renders one
100
+ * -- `importGhidraExport()` would refuse EVERY real captured export outright.
101
+ * Fixed here, in the SAME function `## CONST_WRITES`'s own tokens (which are
102
+ * ALSO bare hex) need the identical treatment for.
103
+ */
104
+ function parseGhidraAddressToken(token: string, what: string): number {
105
+ if (token.startsWith("$") || /^0[xX]/.test(token)) {
106
+ return parseStoreAddress(token, { what });
107
+ }
108
+ if (/^[0-9a-fA-F]+$/.test(token)) {
109
+ const value = parseInt(token, 16);
110
+ if (value < 0 || value > 0xffff) {
111
+ throw new AnnoImportError(
112
+ `anno_import_ghidra_export refused: ${what} ${JSON.stringify(token)} is out of range -- expected $0000-$ffff.`,
113
+ { section: what },
114
+ );
115
+ }
116
+ return value;
117
+ }
118
+ throw new AnnoImportError(
119
+ `anno_import_ghidra_export refused: ${what} ${JSON.stringify(token)} is not a resolvable address -- expected bare ` +
120
+ `hex digits (Ghidra's own rendering) or a "$"/"0x"-prefixed form.`,
121
+ { section: what },
122
+ );
123
+ }
124
+
125
+ /**
126
+ * A single forward pass over `text`. Refuses BY NAME, embedding the section
127
+ * name and the 1-based line number in the message, BEFORE any store write is
128
+ * attempted anywhere downstream -- this function never writes and is called
129
+ * before `importGhidraExport()` issues its first `putXref()`.
130
+ *
131
+ * Refusal conditions, each named in the thrown message:
132
+ * - the text is empty or whitespace-only;
133
+ * - the first non-blank line is not a `## ` section header;
134
+ * - a `## REFERENCES` body line does not match the exact
135
+ * `<from> -> <to> <ReferenceType>` shape (the arrow as the second
136
+ * whitespace-separated token, exactly four tokens total);
137
+ * - a declared `## REFERENCE_COUNT` or `## CLASSIFICATION_LINES` trailer
138
+ * disagrees with the number of body lines actually parsed for that
139
+ * section.
140
+ *
141
+ * A section header present with ZERO following body lines is VALID and
142
+ * yields an empty array for that section -- an empty `## REFERENCES` body
143
+ * followed by `## REFERENCE_COUNT 0` is a legitimate export, not a malformed
144
+ * one.
145
+ */
146
+ export function parseGhidraExport(text: string): GhidraExportDocument {
147
+ if (text.trim() === "") {
148
+ throw new AnnoImportError(
149
+ "anno_import_ghidra_export refused: the transfer file's text is empty or whitespace-only -- an empty file " +
150
+ "is refused as malformed, never treated as an empty-but-valid export.",
151
+ { section: "(document)", line: 1 },
152
+ );
153
+ }
154
+
155
+ const lines = text.split(/\r?\n/);
156
+ const sections = new Map<string, string[]>();
157
+ const trailers = new Map<string, string>();
158
+ let currentSection: string | undefined;
159
+ let sawFirstNonBlank = false;
160
+
161
+ for (let i = 0; i < lines.length; i++) {
162
+ const line = lines[i]!;
163
+ const lineNo = i + 1;
164
+ if (line.trim() === "") continue;
165
+
166
+ if (!sawFirstNonBlank) {
167
+ sawFirstNonBlank = true;
168
+ if (!line.startsWith("## ")) {
169
+ throw new AnnoImportError(
170
+ `anno_import_ghidra_export refused: expected the first non-blank line to be a "## " section header, found ` +
171
+ `${JSON.stringify(line)} at line ${lineNo} -- refusing to parse a headerless document as a bodiless one.`,
172
+ { section: "(document)", line: lineNo },
173
+ );
174
+ }
175
+ }
176
+
177
+ if (line.startsWith("## ")) {
178
+ const rest = line.slice(3);
179
+ const spaceIdx = rest.indexOf(" ");
180
+ if (spaceIdx === -1) {
181
+ currentSection = rest.trim();
182
+ if (!sections.has(currentSection)) sections.set(currentSection, []);
183
+ } else {
184
+ const trailerName = rest.slice(0, spaceIdx);
185
+ const trailerValue = rest.slice(spaceIdx + 1).trim();
186
+ trailers.set(trailerName, trailerValue);
187
+ }
188
+ continue;
189
+ }
190
+
191
+ // A body line belonging to `currentSection`. `currentSection` is always
192
+ // defined here: the first-non-blank-line check above already refused any
193
+ // document whose first line is not a header, so a body line can only be
194
+ // reached after at least one header has been seen.
195
+ if (currentSection === "REFERENCES") {
196
+ const tokens = line.trim().split(/\s+/);
197
+ if (tokens.length !== 4 || tokens[1] !== "->") {
198
+ throw new AnnoImportError(
199
+ `anno_import_ghidra_export refused: REFERENCES line ${lineNo} does not match "<from> -> <to> ` +
200
+ `<ReferenceType>": ${JSON.stringify(line)}`,
201
+ { section: "REFERENCES", line: lineNo },
202
+ );
203
+ }
204
+ }
205
+
206
+ // MEASURED bug, found this plan against a REAL captured export (no prior
207
+ // test ever ran a real `## CLASSIFICATION` section through this parser):
208
+ // `GhidraStructExport.java`'s classification section appends three
209
+ // informational lines AFTER its own `## CLASSIFICATION_LINES <n>`
210
+ // trailer -- `CLASSIFICATION_EXPECTED_FROM_BLOCKS`, `CLASSIFICATION_
211
+ // OBSERVED`, `CLASSIFICATION_OVERRIDE_USED` -- with NO `## ` prefix
212
+ // (verbatim `StringBuilder.append()` calls, never a header/trailer
213
+ // line). Because `currentSection` stays "CLASSIFICATION" across the
214
+ // preceding `## CLASSIFICATION_LINES` TRAILER line (a trailer never
215
+ // changes `currentSection`), these three bare lines would otherwise be
216
+ // swept into `sections.get("CLASSIFICATION")` as ordinary body content,
217
+ // inflating the parsed count by exactly 3 relative to the script's own
218
+ // declared trailer -- silently reddening `checkTrailerCount` below on
219
+ // every real capture. Skipped here BY NAME (never a generic "looks like
220
+ // a trailer" heuristic, which risks dropping a genuine classification
221
+ // line that happens to start similarly).
222
+ if (
223
+ currentSection === "CLASSIFICATION" &&
224
+ /^(CLASSIFICATION_EXPECTED_FROM_BLOCKS|CLASSIFICATION_OBSERVED|CLASSIFICATION_OVERRIDE_USED)\b/.test(line)
225
+ ) {
226
+ continue;
227
+ }
228
+
229
+ sections.get(currentSection!)!.push(line);
230
+ }
231
+
232
+ const checkTrailerCount = (sectionName: string, trailerName: string): void => {
233
+ const declaredRaw = trailers.get(trailerName);
234
+ if (declaredRaw === undefined) return;
235
+ const declared = Number(declaredRaw);
236
+ const actual = sections.get(sectionName)?.length ?? 0;
237
+ if (!Number.isInteger(declared) || declared !== actual) {
238
+ throw new AnnoImportError(
239
+ `anno_import_ghidra_export refused: "## ${trailerName} ${declaredRaw}" declares ${declaredRaw} but ` +
240
+ `${sectionName} carries ${actual} parsed line(s) -- a self-checking count disagreement is refused rather ` +
241
+ "than trusted.",
242
+ { section: sectionName },
243
+ );
244
+ }
245
+ };
246
+ checkTrailerCount("REFERENCES", "REFERENCE_COUNT");
247
+ checkTrailerCount("CLASSIFICATION", "CLASSIFICATION_LINES");
248
+ // The SAME generic trailer-count check, extended to
249
+ // `## CONST_WRITES` / `## CONST_WRITES_COUNT`. Note what this generic
250
+ // grammar already does with the exporter's own `## CONST_WRITES_NONE`
251
+ // marker line: because every `## `-prefixed line with no following space
252
+ // OPENS A NEW SECTION (never a body line), that marker line itself closes
253
+ // out `CONST_WRITES` at zero body lines and opens an unrelated, always-
254
+ // empty `CONST_WRITES_NONE` section -- so a "found nothing" export and a
255
+ // "found nothing, no marker" export are indistinguishable to THIS check,
256
+ // both correctly reporting zero body lines against a `CONST_WRITES_COUNT 0`
257
+ // trailer. `parseConstWrites()` below never needs to special-case the
258
+ // marker itself for exactly this reason.
259
+ checkTrailerCount("CONST_WRITES", "CONST_WRITES_COUNT");
260
+
261
+ return { sections, trailers };
262
+ }
263
+
264
+ /**
265
+ * The frozen mapping from Ghidra's `Reference.getReferenceType()`
266
+ * vocabulary onto the store's four-member `XrefAccessKind`. A token absent
267
+ * from this table is dropped and COUNTED (`kindsSeenNotImported`), never
268
+ * refused and never guessed -- a real corpus binary carries ordinary jump and
269
+ * call references constantly, and refusing on them would make the importer
270
+ * unusable.
271
+ */
272
+ export const GHIDRA_REFTYPE_TO_ACCESS_KIND: Readonly<Record<string, XrefAccessKind>> = Object.freeze({
273
+ READ: "READ",
274
+ WRITE: "WRITE",
275
+ READ_WRITE: "READ_WRITE",
276
+ COMPUTED_JUMP: "COMPUTED_JUMP",
277
+ COMPUTED_CALL: "COMPUTED_JUMP",
278
+ });
279
+
280
+ /** The watched-address set, mirroring the Java constant
281
+ * `CONST_WRITE_WATCHED_ADDRESSES` in `GhidraStructExport.java` byte-for-byte
282
+ * -- the two MUST be kept in step. `parseConstWrites()` below does NOT
283
+ * filter its own output against this list: a `## CONST_WRITES` line for an
284
+ * address outside this set is still parsed and returned, because the
285
+ * EXPORTER owns the watched set, and a parser that silently dropped a
286
+ * widened set would hide the widening from every caller rather than
287
+ * surfacing it. */
288
+ export const CONST_WRITE_WATCHED_ADDRESSES: readonly number[] = Object.freeze([0x0001, 0xd011, 0xd018, 0xdd00]);
289
+
290
+ /** One resolved immediate store to a watched address, per `## CONST_WRITES`
291
+ * line: the instruction's own address, the memory address it wrote to, and
292
+ * the compile-time constant it wrote. All three are plain numbers -- callers
293
+ * needing `$`-formatted text format them themselves. */
294
+ export interface ConstWriteFact {
295
+ storeAddress: number;
296
+ targetAddress: number;
297
+ value: number;
298
+ }
299
+
300
+ /**
301
+ * Turns the `## CONST_WRITES` section `parseGhidraExport()` already parsed
302
+ * into `(storeAddress, targetAddress, value)` facts. `parseGhidraExport()`
303
+ * has ALREADY refused, before
304
+ * this function is ever called, if a declared `## CONST_WRITES_COUNT`
305
+ * trailer disagrees with the section's own parsed body-line count -- the
306
+ * SAME generic check `## REFERENCE_COUNT`/`## CLASSIFICATION_LINES` already
307
+ * use. This function refuses on its own only for a body line whose token
308
+ * count is not exactly three (the importer's own named error, section and
309
+ * 1-BASED-WITHIN-THE-SECTION line number), or whose address/value token is
310
+ * not a resolvable constant (`parseGhidraAddressToken()`'s own refusal,
311
+ * reused rather than duplicated -- never a hand-rolled second numeric
312
+ * parser).
313
+ *
314
+ * A section carrying only the exporter's own `## CONST_WRITES_NONE` marker
315
+ * line yields an empty array, not an error: per `parseGhidraExport()`'s
316
+ * generic `## `-header grammar, that marker line itself OPENS A NEW,
317
+ * unrelated section (every `## `-prefixed line with no following space does)
318
+ * rather than being a `CONST_WRITES` body line, so `CONST_WRITES` is left
319
+ * with zero parsed body lines either way -- there is nothing here to
320
+ * special-case. A document that never mentions `CONST_WRITES` at all (no
321
+ * section key, no count trailer) yields the same empty array.
322
+ */
323
+ export function parseConstWrites(document: GhidraExportDocument): ConstWriteFact[] {
324
+ const lines = document.sections.get("CONST_WRITES") ?? [];
325
+ const facts: ConstWriteFact[] = [];
326
+ for (let i = 0; i < lines.length; i++) {
327
+ const line = lines[i]!;
328
+ const lineNo = i + 1;
329
+ const tokens = line.trim().split(/\s+/);
330
+ if (tokens.length !== 3) {
331
+ throw new AnnoImportError(
332
+ `anno_import_ghidra_export refused: CONST_WRITES line ${lineNo} does not match "<store-address> ` +
333
+ `<target-address> <value>": ${JSON.stringify(line)}`,
334
+ { section: "CONST_WRITES", line: lineNo },
335
+ );
336
+ }
337
+ const storeAddress = parseGhidraAddressToken(tokens[0]!, `CONST_WRITES line ${lineNo} storeAddress`);
338
+ const targetAddress = parseGhidraAddressToken(tokens[1]!, `CONST_WRITES line ${lineNo} targetAddress`);
339
+ const value = parseGhidraAddressToken(tokens[2]!, `CONST_WRITES line ${lineNo} value`);
340
+ facts.push({ storeAddress, targetAddress, value });
341
+ }
342
+ return facts;
343
+ }
344
+
345
+ /** What one `importGhidraExport()` call reports. */
346
+ export interface ImportCounts {
347
+ referencesSeen: number;
348
+ xrefsWritten: number;
349
+ xrefsAlreadyPresent: number;
350
+ kindsSeenNotImported: Record<string, number>;
351
+ transferPath: string;
352
+ transferSha256: string;
353
+ transferByteLength: number;
354
+ transferDeleted: boolean;
355
+ /** Present only when `transferDeleted` is `false` because the unlink
356
+ * itself threw -- the writes had already committed, so the import is
357
+ * still reported as a success with this reason attached, never as a
358
+ * failure after a durable write. */
359
+ transferDeleteError?: string;
360
+ /** The `## CONST_WRITES` section's own facts, parsed by
361
+ * `parseConstWrites()` BEFORE the transfer file is deleted below -- the
362
+ * ONE artifact carrying them. `importGhidraExport()` never persists these
363
+ * facts in the store (the reserved `bank` column stays null);
364
+ * they ride on THIS return value instead, so a caller can hand the SAME
365
+ * array straight to `anno_join_memmap`'s own `const_writes` argument in a
366
+ * following call, closing the loop `anno-tools.ts`'s `dispatchJoinMemmap()`
367
+ * previously left open (every call resolved through the unconstrained path
368
+ * because nothing ever supplied `runMemmapJoin()`'s `constWrites`).
369
+ * Always present, even when empty -- mirrors this interface's own
370
+ * "always present, often empty" siblings rather than being conditionally
371
+ * omitted. */
372
+ constWrites: ConstWriteFact[];
373
+ }
374
+
375
+ export interface ImportGhidraExportArgs {
376
+ exportPath: string;
377
+ expectedSha256?: string;
378
+ /** Test-only injection point for the delete step. Exists so "the writes
379
+ * committed but the delete itself failed" path (T-37-03) can be exercised
380
+ * DETERMINISTICALLY: making a directory read-only does not reliably block
381
+ * a delete when the test process runs as root (root ignores permission
382
+ * bits, and CI containers commonly run as root), so a caller-supplied
383
+ * removal function is the portable route. Defaults to the real deletion
384
+ * via `deleteTransferFile()` below. */
385
+ deleteFile?: (path: string) => void;
386
+ }
387
+
388
+ /** The real deletion step, defined once so `importGhidraExport()`'s own
389
+ * call site never spells the removal syscall's name directly -- a later
390
+ * acceptance gate greps this file for the literal token `unlinkSync` and
391
+ * requires it to appear on EXACTLY ONE non-comment line, which is this one. */
392
+ function deleteTransferFile(path: string): void {
393
+ fs.unlinkSync(path);
394
+ }
395
+
396
+ /**
397
+ * Imports one host-written transfer file into `handle`. Reads the file's
398
+ * bytes ONCE and derives both the digest and the byte length from that same
399
+ * buffer (mirroring `digestOutputFile()`'s shape at `host-tool.mts:1361-1372`
400
+ * -- the size and the hash must describe the same bytes). Parses fully,
401
+ * builds the mapped write list fully, and only THEN issues every `putXref()`
402
+ * call in file order. Deletes the transfer file in the LAST statement of the
403
+ * successful path, after every write has returned -- `putXref()` commits
404
+ * inside `applyWrite()` before returning, so a returned write is durable.
405
+ */
406
+ export function importGhidraExport(handle: AnnoStoreHandle, args: ImportGhidraExportArgs): ImportCounts {
407
+ const { exportPath } = args;
408
+
409
+ if (!fs.existsSync(exportPath)) {
410
+ throw new AnnoImportError(
411
+ `anno_import_ghidra_export refused: no transfer file exists at ${JSON.stringify(exportPath)}. Nothing was ` +
412
+ "read, nothing was written.",
413
+ { section: "(file)" },
414
+ );
415
+ }
416
+
417
+ const contents = fs.readFileSync(exportPath);
418
+ const transferSha256 = createHash("sha256").update(contents).digest("hex");
419
+ const transferByteLength = contents.length;
420
+
421
+ if (args.expectedSha256 !== undefined && args.expectedSha256 !== transferSha256) {
422
+ throw new AnnoImportError(
423
+ `anno_import_ghidra_export refused: expected sha256 ${args.expectedSha256} but the transfer file at ` +
424
+ `${JSON.stringify(exportPath)} hashes to ${transferSha256} -- this digest is a corruption/drift detector, ` +
425
+ "never a security boundary, and a mismatch refuses before anything is written or deleted.",
426
+ { section: "(digest)" },
427
+ );
428
+ }
429
+
430
+ const doc = parseGhidraExport(contents.toString("utf8"));
431
+ const referenceLines = doc.sections.get("REFERENCES") ?? [];
432
+ // Parsed here, from the SAME document, before the
433
+ // transfer file is deleted below -- `parseConstWrites()` was previously
434
+ // exercised only by test code (`anno-join.test.ts`/`ghidra-live.test.ts`),
435
+ // never by this, the only production entry point that reads a transfer
436
+ // file. See `ImportCounts.constWrites`'s own doc comment for how the
437
+ // facts reach `anno_join_memmap`.
438
+ const constWrites = parseConstWrites(doc);
439
+
440
+ const kindsSeenNotImported: Record<string, number> = {};
441
+ const writes: { fromAddress: number; toAddress: number; accessKind: XrefAccessKind }[] = [];
442
+ let referencesSeen = 0;
443
+
444
+ for (const line of referenceLines) {
445
+ referencesSeen += 1;
446
+ const tokens = line.trim().split(/\s+/);
447
+ const fromToken = tokens[0]!;
448
+ const toToken = tokens[2]!;
449
+ const kindToken = tokens[3]!;
450
+ const mappedKind = GHIDRA_REFTYPE_TO_ACCESS_KIND[kindToken];
451
+ if (mappedKind === undefined) {
452
+ kindsSeenNotImported[kindToken] = (kindsSeenNotImported[kindToken] ?? 0) + 1;
453
+ continue;
454
+ }
455
+ const fromAddress = parseGhidraAddressToken(fromToken, "REFERENCES fromAddress");
456
+ const toAddress = parseGhidraAddressToken(toToken, "REFERENCES toAddress");
457
+ writes.push({ fromAddress, toAddress, accessKind: mappedKind });
458
+ }
459
+
460
+ // PHASE TWO: every write is issued only after the whole document parsed
461
+ // and the whole write list was built above. No `putXref()` call happens
462
+ // before this point.
463
+ let xrefsWritten = 0;
464
+ let xrefsAlreadyPresent = 0;
465
+ for (const write of writes) {
466
+ const result = putXref(handle, write);
467
+ if (result.changed) xrefsWritten += 1;
468
+ else xrefsAlreadyPresent += 1;
469
+ }
470
+
471
+ // THE SINGLE DELETE CALL SITE. It runs here, after the loop above has
472
+ // fully returned, and nowhere else in this file.
473
+ const remove = args.deleteFile ?? deleteTransferFile;
474
+ let transferDeleted = true;
475
+ let transferDeleteError: string | undefined;
476
+ try {
477
+ remove(exportPath);
478
+ } catch (err) {
479
+ transferDeleted = false;
480
+ transferDeleteError = err instanceof Error ? err.message : String(err);
481
+ }
482
+
483
+ return {
484
+ referencesSeen,
485
+ xrefsWritten,
486
+ xrefsAlreadyPresent,
487
+ kindsSeenNotImported,
488
+ transferPath: exportPath,
489
+ transferSha256,
490
+ transferByteLength,
491
+ transferDeleted,
492
+ constWrites,
493
+ ...(transferDeleteError !== undefined ? { transferDeleteError } : {}),
494
+ };
495
+ }
package/anno-index.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  //
4
4
  // The ONE place that answers "which annotated range owns this address" --
5
5
  // a pure, narrowest-range-wins paint index over the whole 64K address space,
6
- // rebuilt from rows and never maintained incrementally (STORE-03).
6
+ // rebuilt from rows and never maintained incrementally.
7
7
  //
8
8
  // ---------------------------------------------------------------------------
9
9
  // WHY THIS FILE EXISTS
@@ -37,14 +37,14 @@
37
37
  // an argument is the only shape that can be tested for it.
38
38
  // 2. NEVER cache this index on disk. A cached index is a second truth that
39
39
  // can disagree with the range table -- exactly the failure the
40
- // derived-from-bytes coverage census (`COV-01`) exists to make impossible.
40
+ // derived-from-bytes coverage census exists to make impossible.
41
41
  // Rebuild it; see the measurement above.
42
- // 3. NEVER add an adjacency, coalescing or merge pass. `STORE-02` stores
43
- // ranges AS ranges and never merges them, so there is no splitter
44
- // primitive here to introduce. Merging is the named blocker behind
45
- // `DECOMP-01` and `BUILD-02` and the predicted over-merge bias behind
46
- // `COV-01`; all three are designed out by construction here, not
47
- // documented as hazards to remember.
42
+ // 3. NEVER add an adjacency, coalescing or merge pass. This project's own
43
+ // store stores ranges AS ranges and never merges them, so there is no
44
+ // splitter primitive here to introduce. Merging is a named blocker for
45
+ // decomposition-to-closure work and the predicted over-merge bias
46
+ // behind the coverage census; all three are designed out by
47
+ // construction here, not documented as hazards to remember.
48
48
  // 4. NEVER maintain the index incrementally. An incremental update has to
49
49
  // know what the row it is removing was covering UNDERNEATH, which the
50
50
  // index cannot say -- a painted cell records the winner, not the losers.