@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.
- package/README.md +2 -2
- package/THIRD-PARTY-NOTICES.md +422 -1
- package/anno-bank.ts +171 -0
- package/anno-cli.ts +1736 -163
- package/anno-confidence.ts +2 -2
- package/anno-derive.ts +6 -6
- package/anno-details.ts +4 -4
- package/anno-enum-gen.ts +416 -30
- package/anno-export-asm.ts +1211 -126
- package/anno-graphics.ts +338 -0
- package/anno-hazard-report.ts +1367 -0
- package/anno-import.ts +495 -0
- package/anno-index.ts +8 -8
- package/anno-join.ts +480 -0
- package/anno-memmap-render.ts +22 -21
- package/anno-provenance-ledger.ts +472 -0
- package/anno-regbits-gen.ts +13 -13
- package/anno-register.ts +159 -0
- package/anno-store-export.ts +661 -0
- package/anno-store.ts +635 -124
- package/anno-symbols.ts +7 -7
- package/anno-tools.ts +1169 -16
- package/anno-types.ts +313 -40
- package/backend-detect.mts +124 -312
- package/build.ts +3 -1
- package/capture-predicate.ts +597 -0
- package/channel-lock.ts +349 -0
- package/evid-ingest.ts +217 -0
- package/evid-reconcile.ts +316 -0
- package/host-tool-client.ts +430 -0
- package/incident-record.ts +23 -12
- package/install-resources.ts +29 -13
- package/memmap-lookup.ts +285 -0
- package/package.json +27 -8
- package/prg-image.ts +1 -2
- package/repo-root.ts +87 -3
- package/resources/backend-detect.mjs +98 -236
- package/resources/broker-control.mjs +220 -54
- package/resources/broker-epoch.mjs +7 -8
- package/resources/broker-kill.mjs +36 -31
- package/resources/broker-launch.mjs +511 -374
- package/resources/broker-state.mjs +69 -24
- package/resources/container-guard.mjs +1 -1
- package/resources/ghidra-project.mjs +790 -0
- package/resources/host-tool.mjs +2533 -0
- package/resources/vice-broker.mjs +434 -290
- package/resources/vice-launcher.sh +127 -9
- package/stock-address.ts +1 -1
- package/stock-condition.ts +1 -1
- package/stock-connect.ts +9 -5
- package/stock-derived.ts +29 -37
- package/stock-diagnose.ts +200 -36
- package/stock-dispatch.ts +179 -77
- package/stock-handler.ts +1 -1
- package/stock-paths.ts +18 -14
- package/stock-petscii.ts +1 -1
- package/stock-protocol.ts +1 -1
- package/stock-recycle.ts +83 -2
- package/stock-reproducible-run.ts +811 -0
- package/stock-run-until.ts +100 -1
- package/stock-symbols.ts +4 -4
- package/stock-timing.ts +1 -1
- package/stop-oracle.ts +167 -0
- package/text-capability-probe.ts +660 -0
- package/text-connect.ts +157 -0
- package/text-protocol.ts +810 -0
- package/text-tools.ts +778 -0
- package/textmon-backtrace.ts +385 -0
- package/textmon-cpuhistory.ts +335 -0
- package/textmon-memmap.ts +494 -0
- package/textmon-profile.ts +458 -0
- package/textmon-registers.ts +748 -0
- package/tools-manifest.stock.json +864 -3
- package/vice-broker-client.ts +253 -108
- package/vice-errors.ts +268 -0
- package/vice-proxy.ts +339 -2144
- package/vsf-slice.ts +640 -0
- package/anno-d64.ts +0 -310
- package/capability-registry.ts +0 -390
- package/refresh-manifest.ts +0 -124
- package/tools-manifest.json +0 -1223
- package/vice-probe.ts +0 -278
- package/vice-sync.ts +0 -336
- 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
|
|
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
|
|
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.
|
|
43
|
-
// ranges AS ranges and never merges them, so there is no
|
|
44
|
-
// primitive here to introduce. Merging is
|
|
45
|
-
//
|
|
46
|
-
//
|
|
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.
|