@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
|
@@ -0,0 +1,790 @@
|
|
|
1
|
+
// GENERATED FILE -- DO NOT EDIT.
|
|
2
|
+
// Compiled by `tsc` from ghidra-project.mts. Edit the TypeScript source and rebuild;
|
|
3
|
+
// changes made directly to this file are silently overwritten by the next build, and are never
|
|
4
|
+
// deployed to the host on their own -- install-resources.mjs copies THIS file's on-disk contents
|
|
5
|
+
// verbatim to .c64-re-tools/bin/, so an edit made only here reaches the host but is lost on the very next
|
|
6
|
+
// rebuild.
|
|
7
|
+
// ghidra-project.mts
|
|
8
|
+
//
|
|
9
|
+
// Phase 34, plan 34-03 (SEAM-04): the dot-segment refusal and the per-run
|
|
10
|
+
// Ghidra project location, enforced in THIS PROJECT'S OWN CODE before
|
|
11
|
+
// `analyzeHeadless` is ever reached. Motivated by 34-RESEARCH.md Finding 2:
|
|
12
|
+
// this session ran real Ghidra 12.1.3 twice and found the refusal walks the
|
|
13
|
+
// ENTIRE absolute project-location path, not just the leaf --
|
|
14
|
+
// `ghidra.util.NamingUtilities.checkName()` -> `GhidraURL.checkValidProjectPath()`
|
|
15
|
+
// -> `ghidra.framework.model.ProjectLocator`'s constructor -- firing after
|
|
16
|
+
// 12-16 seconds of JVM startup with a hard `exit 1` and the literal message
|
|
17
|
+
// "Path element starting with '.' is not permitted". A client-side
|
|
18
|
+
// per-segment check is not merely tidier here; it is the ONLY way to
|
|
19
|
+
// satisfy "refused before analyzeHeadless is ever reached" literally,
|
|
20
|
+
// because Ghidra's own refusal necessarily happens INSIDE a running
|
|
21
|
+
// `analyzeHeadless` process.
|
|
22
|
+
//
|
|
23
|
+
// THIS IS THE ONE AUTHORITATIVE PLACE for three things, none of which may
|
|
24
|
+
// be re-derived anywhere else -- in particular not copied into
|
|
25
|
+
// host-tool.mts, which reaches these by a VALUE import of this module's
|
|
26
|
+
// compiled `.mjs` sibling instead:
|
|
27
|
+
// - the dot-segment rule (hasDotPrefixedSegment());
|
|
28
|
+
// - the per-run project location (resolveGhidraProject()) -- a fresh,
|
|
29
|
+
// never-reused directory per run id, which is what makes Ghidra's
|
|
30
|
+
// single-writer project lock UNREACHABLE rather than merely guarded;
|
|
31
|
+
// - analyzeHeadless argv construction (buildAnalyzeHeadlessArgv()), which
|
|
32
|
+
// re-runs the dot-segment check independently so the rule holds even
|
|
33
|
+
// for a caller that skipped the resolver entirely, AND (34-07, CR-02)
|
|
34
|
+
// independently refuses a preScript/postScript carrying a
|
|
35
|
+
// parent-directory path segment, for the same reason.
|
|
36
|
+
//
|
|
37
|
+
// WHAT NOT TO DO, each naming the prohibition it guards (34-03-PLAN.md's
|
|
38
|
+
// own `must_haves.prohibitions`):
|
|
39
|
+
// - Never implement the refusal by parsing Ghidra's own stderr for the
|
|
40
|
+
// `IllegalArgumentException` string -- that would mean paying the
|
|
41
|
+
// ~12-16s JVM startup cost to learn a fact a string comparison already
|
|
42
|
+
// knows.
|
|
43
|
+
// - Never reuse a project directory across run ids -- reuse is exactly
|
|
44
|
+
// what makes the single-writer lock reachable again.
|
|
45
|
+
// - Never hardcode an absolute runs-root path -- it is always derived
|
|
46
|
+
// from a caller-supplied repoRoot via ghidraRunsRoot().
|
|
47
|
+
// - No second copy of the dot-segment rule anywhere outside this file.
|
|
48
|
+
// - No child-process call, no reference to an `analyzeHeadless`
|
|
49
|
+
// executable path, anywhere in this module -- it is pure string,
|
|
50
|
+
// filesystem-EXISTENCE-check, and filesystem-CREATE logic, provable
|
|
51
|
+
// with no Ghidra installation present. (`existsSync`/`mkdirSync` below
|
|
52
|
+
// only ever check or create a directory this project itself is about
|
|
53
|
+
// to hand to a FUTURE `analyzeHeadless` invocation -- neither ever
|
|
54
|
+
// invokes anything.)
|
|
55
|
+
//
|
|
56
|
+
// LIVE FINDING, this plan's own Task 3 (not stated by 34-RESEARCH.md
|
|
57
|
+
// Finding 2, which only ever exercised the REFUSAL path): a real Ghidra
|
|
58
|
+
// 12.1.3 run against a CLEAN, non-dotted, well-formed project location that
|
|
59
|
+
// does not yet exist on disk fails with `java.io.FileNotFoundException:
|
|
60
|
+
// Directory not found` at `DefaultProjectManager.createProject()` --
|
|
61
|
+
// `analyzeHeadless` does not create the leaf project directory itself, on
|
|
62
|
+
// EITHER path (refusal or success). resolveGhidraProject() therefore
|
|
63
|
+
// CREATES the directory as the last step of a successful resolution (never
|
|
64
|
+
// on a refusal) -- this is what makes it a genuine RESERVATION, not just a
|
|
65
|
+
// path computation: the moment a caller receives `ok: true`, the directory
|
|
66
|
+
// exists and a second call under the same run id is refused, with no
|
|
67
|
+
// window where two callers could observe an absent directory and both
|
|
68
|
+
// proceed. See `evidence/34-ghidra-dotpath.md` for the full transcript.
|
|
69
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, symlinkSync } from "node:fs";
|
|
70
|
+
import { join, sep } from "node:path";
|
|
71
|
+
/** The refusal-message fragment naming Ghidra's own literal error text, in
|
|
72
|
+
* ONE place, so every refusal in this module (and any caller reading a
|
|
73
|
+
* refusal message) can quote the same words Ghidra itself would have used
|
|
74
|
+
* ~12-16 seconds later. */
|
|
75
|
+
export const DOT_SEGMENT_REFUSAL = "path element starting with '.' is not permitted";
|
|
76
|
+
/** Gap G-40-1 (2026-09-08): the Ghidra runs root moved UNDER the single
|
|
77
|
+
* tool-written root (`.c64-re-tools/`, D-33) via a broker-minted symlink
|
|
78
|
+
* HANDLE, not by relocating the physical bytes anywhere else. This corrects
|
|
79
|
+
* an earlier, unmeasured claim recorded right here -- see "What this
|
|
80
|
+
* corrects" below.
|
|
81
|
+
*
|
|
82
|
+
* MEASURED against real Ghidra 12.1.3
|
|
83
|
+
* (`.planning/notes/ghidra-dot-path-check-semantics.md`: 5 live
|
|
84
|
+
* `analyzeHeadless` runs plus a `javap` read of `ProjectLocator.class`): the
|
|
85
|
+
* dot-segment refusal binds the ABSOLUTIZED project-location path argument
|
|
86
|
+
* -- `ProjectLocator` calls `java.io.File.getAbsolutePath()` and never
|
|
87
|
+
* `getCanonicalPath()`. So:
|
|
88
|
+
* - a RELATIVE location is absolutized against the process cwd and then
|
|
89
|
+
* refused if that absolutized path carries a dotted segment;
|
|
90
|
+
* - a bare `.` segment survives absolutization and is itself caught;
|
|
91
|
+
* - a SYMLINK is NOT resolved -- Ghidra never canonicalises the path, so a
|
|
92
|
+
* non-dotted symlink into a dotted directory is ACCEPTED. Proven by a
|
|
93
|
+
* FULL import plus analysis run through such a link
|
|
94
|
+
* (`REPORT: Analysis succeeded`, 2.1M of program database physically
|
|
95
|
+
* under `.c64-re-tools/`), not merely by project creation -- an earlier,
|
|
96
|
+
* less complete run had mistaken creation alone for proof.
|
|
97
|
+
*
|
|
98
|
+
* The chosen location therefore satisfies three conditions SIMULTANEOUSLY,
|
|
99
|
+
* all three load-bearing and none of them optional:
|
|
100
|
+
* 1. No dotted or bare-dot segment anywhere in the ABSOLUTIZED path handed
|
|
101
|
+
* to `analyzeHeadless` (this file's own `hasDotPrefixedSegment()`).
|
|
102
|
+
* 2. The path stays INSIDE the bind-mounted workspace tree --
|
|
103
|
+
* `containerPath()` THROWS on a host path matching no known host root
|
|
104
|
+
* (`containerpath.ts`), so an out-of-workspace handle (a `/tmp` or
|
|
105
|
+
* XDG-cache location) breaks the container route outright.
|
|
106
|
+
* 3. The link TARGET is RELATIVE, not absolute -- the CONTAINER itself
|
|
107
|
+
* traverses this handle to read the per-run log (`ghidra-run.ts:241`),
|
|
108
|
+
* and an absolute HOST-side target names a path that does not exist
|
|
109
|
+
* inside the container, so every run-log read would ENOENT.
|
|
110
|
+
*
|
|
111
|
+
* What this corrects: an earlier version of this comment stated re-pointing
|
|
112
|
+
* the runs root under `.c64-re-tools/` would make `resolveGhidraProject()`
|
|
113
|
+
* "refuse EVERY call, unconditionally," and called that "a hard
|
|
114
|
+
* external-tool constraint, not a preference." Both claims were inferred
|
|
115
|
+
* from running THIS MODULE'S OWN `hasDotPrefixedSegment()` against a
|
|
116
|
+
* synthetic `.c64-re-tools/runs/ghidra/<runId>` string -- a self-referential
|
|
117
|
+
* check that observes this project's own predicate, never Ghidra itself.
|
|
118
|
+
* That inference is superseded by the measurement above; do not
|
|
119
|
+
* reconstruct it from the same self-referential method a second time.
|
|
120
|
+
*
|
|
121
|
+
* Fragility this design knowingly accepts: the symlink route depends on
|
|
122
|
+
* Ghidra continuing to call `getAbsolutePath()` rather than
|
|
123
|
+
* `getCanonicalPath()` -- a one-word upstream change would silently break
|
|
124
|
+
* every run with the same misleading dot-segment error, ~12-16s late. Plan
|
|
125
|
+
* 40-09 adds a live guard for this; this module still must NEVER implement
|
|
126
|
+
* that guard by string-matching Ghidra's own stderr (this file's header,
|
|
127
|
+
* above, already prohibits paying the JVM-startup cost to learn a fact a
|
|
128
|
+
* string comparison already knows). */
|
|
129
|
+
export const GHIDRA_RUNS_HANDLE_NAME = "c64-re-tools";
|
|
130
|
+
/** The symlink TARGET `ensureGhidraRunsHandle()` mints at
|
|
131
|
+
* `<repoRoot>/GHIDRA_RUNS_HANDLE_NAME` -- the RELATIVE string `.c64-re-tools`
|
|
132
|
+
* (condition 3 above), never an absolute path. Relative so the CONTAINER
|
|
133
|
+
* resolves it against the symlink's own directory rather than a host-only
|
|
134
|
+
* absolute path. */
|
|
135
|
+
export const GHIDRA_RUNS_HANDLE_TARGET = ".c64-re-tools";
|
|
136
|
+
/** THE one place the path HANDED TO GHIDRA is computed: the repo root, the
|
|
137
|
+
* non-dotted handle segment, then `runs`, then `ghidra`. Every consumer
|
|
138
|
+
* (`resolveGhidraProject()` below, `host-tool.mts`'s run-log path, any
|
|
139
|
+
* future caller) derives the runs root from this function rather than
|
|
140
|
+
* re-joining the segments itself. */
|
|
141
|
+
export function ghidraRunsRoot(repoRoot) {
|
|
142
|
+
return join(repoRoot, GHIDRA_RUNS_HANDLE_NAME, "runs", "ghidra");
|
|
143
|
+
}
|
|
144
|
+
/** The PHYSICAL location the same runs live at, reached through the handle
|
|
145
|
+
* symlink `ghidraRunsRoot()` points at -- the repo root, the dotted target
|
|
146
|
+
* segment, then `runs`, then `ghidra`. Exported so a test (or a filesystem
|
|
147
|
+
* audit) can assert the D-33 "physically under `.c64-re-tools/`" truth
|
|
148
|
+
* without re-deriving this shape itself. */
|
|
149
|
+
export function ghidraRunsRealRoot(repoRoot) {
|
|
150
|
+
return join(repoRoot, GHIDRA_RUNS_HANDLE_TARGET, "runs", "ghidra");
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Mints (or verifies) the broker-owned symlink HANDLE that lets a Ghidra
|
|
154
|
+
* project location satisfy the dot-segment refusal while the bytes
|
|
155
|
+
* genuinely live under `.c64-re-tools/` (D-33). Never throws; idempotent;
|
|
156
|
+
* NEVER repairs a wrong or foreign handle -- refuses BY NAME instead. This
|
|
157
|
+
* is the precondition that makes the reservation `mkdirSync` in
|
|
158
|
+
* `resolveGhidraProject()` below safe: without it, a missing handle would
|
|
159
|
+
* let recursive `mkdir` silently materialise a REAL directory tree at the
|
|
160
|
+
* handle path, and D-33 would break invisibly (the gap this whole function
|
|
161
|
+
* closes).
|
|
162
|
+
*
|
|
163
|
+
* Order, so a dangling link is never observable:
|
|
164
|
+
* 1. Narrow `repoRoot` the same way `resolveGhidraProject()` does.
|
|
165
|
+
* 2. Create the PHYSICAL runs tree first, recursively, at
|
|
166
|
+
* `ghidraRunsRealRoot()`. On failure, refuse naming that path and the
|
|
167
|
+
* underlying error.
|
|
168
|
+
* 3. `lstatSync()` the handle path. On ENOENT, `symlinkSync()` the
|
|
169
|
+
* RELATIVE target at the handle path. `EEXIST` is caught specifically
|
|
170
|
+
* and falls through to step 4 rather than being treated as a failure --
|
|
171
|
+
* two host-side callers (the broker at startup, a concurrent
|
|
172
|
+
* brokerless spawn) can race here, and the loser of that race must
|
|
173
|
+
* observe success, not a spurious error. No lock is taken and none is
|
|
174
|
+
* needed: both callers write the identical link.
|
|
175
|
+
* 4. VERIFY unconditionally, including immediately after minting -- one
|
|
176
|
+
* verification path, no trust placed in this function's own write.
|
|
177
|
+
* `lstatSync()` must report a symbolic link, and `readlinkSync()` must
|
|
178
|
+
* equal `GHIDRA_RUNS_HANDLE_TARGET` by EXACT string comparison.
|
|
179
|
+
* Anything else refuses.
|
|
180
|
+
*
|
|
181
|
+
* A refusal NEVER repairs: no unlink, no replace, no rename. Whatever
|
|
182
|
+
* already sits at the handle path may be a user's own deliberate directory,
|
|
183
|
+
* a stale artifact, or a redirect -- silently replacing it is exactly the
|
|
184
|
+
* invisible-breakage shape this function exists to close.
|
|
185
|
+
*/
|
|
186
|
+
export function ensureGhidraRunsHandle(repoRoot) {
|
|
187
|
+
if (typeof repoRoot !== "string" || repoRoot === "") {
|
|
188
|
+
return { ok: false, message: `ensureGhidraRunsHandle requires a non-empty string "repoRoot"; got ${describe(repoRoot)}` };
|
|
189
|
+
}
|
|
190
|
+
const realRoot = ghidraRunsRealRoot(repoRoot);
|
|
191
|
+
try {
|
|
192
|
+
mkdirSync(realRoot, { recursive: true });
|
|
193
|
+
}
|
|
194
|
+
catch (e) {
|
|
195
|
+
return { ok: false, message: `ensureGhidraRunsHandle failed to create the physical runs tree (${realRoot}): ${e instanceof Error ? e.message : String(e)}` };
|
|
196
|
+
}
|
|
197
|
+
const handlePath = join(repoRoot, GHIDRA_RUNS_HANDLE_NAME);
|
|
198
|
+
let handleAlreadyExists = true;
|
|
199
|
+
try {
|
|
200
|
+
lstatSync(handlePath);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
handleAlreadyExists = false;
|
|
204
|
+
}
|
|
205
|
+
if (!handleAlreadyExists) {
|
|
206
|
+
try {
|
|
207
|
+
symlinkSync(GHIDRA_RUNS_HANDLE_TARGET, handlePath);
|
|
208
|
+
}
|
|
209
|
+
catch (e) {
|
|
210
|
+
const code = e instanceof Error && "code" in e ? e.code : undefined;
|
|
211
|
+
if (code !== "EEXIST") {
|
|
212
|
+
return {
|
|
213
|
+
ok: false,
|
|
214
|
+
message: `ensureGhidraRunsHandle failed to create the handle symlink (${handlePath} -> ${GHIDRA_RUNS_HANDLE_TARGET}): ${e instanceof Error ? e.message : String(e)}`,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
// EEXIST: a concurrent caller won the race and created the identical
|
|
218
|
+
// link between our lstatSync() above and this symlinkSync() call --
|
|
219
|
+
// fall through to unconditional verification rather than treating
|
|
220
|
+
// this as a failure.
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// VERIFY unconditionally -- never trust the write above, whichever branch
|
|
224
|
+
// took it.
|
|
225
|
+
let stat;
|
|
226
|
+
try {
|
|
227
|
+
stat = lstatSync(handlePath);
|
|
228
|
+
}
|
|
229
|
+
catch (e) {
|
|
230
|
+
return {
|
|
231
|
+
ok: false,
|
|
232
|
+
message: `ensureGhidraRunsHandle: the handle (${handlePath}) does not exist after a creation attempt: ${e instanceof Error ? e.message : String(e)}`,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
if (!stat.isSymbolicLink()) {
|
|
236
|
+
const kind = stat.isDirectory() ? "a directory" : stat.isFile() ? "a file" : "neither a directory, a file, nor a symbolic link";
|
|
237
|
+
return {
|
|
238
|
+
ok: false,
|
|
239
|
+
message: `ensureGhidraRunsHandle refuses: ${handlePath} already exists and is ${kind}, not a symbolic link. ` +
|
|
240
|
+
`This handle must remain a broker-minted alias pointing at the relative target "${GHIDRA_RUNS_HANDLE_TARGET}" -- ` +
|
|
241
|
+
`it is never deleted, replaced, or repaired automatically. Remove it by hand if it is safe to do so, then retry.`,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
// Guarded for the same reason the two lstatSync() calls above are: this
|
|
245
|
+
// function documents "Never throws", and its broker call site
|
|
246
|
+
// (vice-broker.mts run(), requirement R2) sits deliberately OUTSIDE any
|
|
247
|
+
// try/catch so that a refusal is reported without stopping startup. An
|
|
248
|
+
// unguarded throw here would instead reach main()'s outer catch and abort
|
|
249
|
+
// the whole broker -- the exact opposite of R2's "a broker that cannot mint
|
|
250
|
+
// the handle still starts and says so". The window is narrow but real: the
|
|
251
|
+
// handle can be deleted or replaced between the verifying lstatSync() above
|
|
252
|
+
// and this readlinkSync() (TOCTOU). Found by code review, plan 40-09.
|
|
253
|
+
let target;
|
|
254
|
+
try {
|
|
255
|
+
target = readlinkSync(handlePath);
|
|
256
|
+
}
|
|
257
|
+
catch (e) {
|
|
258
|
+
return {
|
|
259
|
+
ok: false,
|
|
260
|
+
message: `ensureGhidraRunsHandle: ${handlePath} verified as a symbolic link but its target could not be read ` +
|
|
261
|
+
`(it was most likely removed or replaced concurrently): ${e instanceof Error ? e.message : String(e)}`,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
if (target !== GHIDRA_RUNS_HANDLE_TARGET) {
|
|
265
|
+
return {
|
|
266
|
+
ok: false,
|
|
267
|
+
message: `ensureGhidraRunsHandle refuses: ${handlePath} is a symbolic link but points to "${target}", not the expected ` +
|
|
268
|
+
`relative target "${GHIDRA_RUNS_HANDLE_TARGET}" -- an absolute or otherwise-wrong target breaks the container ` +
|
|
269
|
+
`route, which traverses this link to read the per-run log. It is never repaired automatically. Remove it by ` +
|
|
270
|
+
`hand if it is safe to do so, then retry.`,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
return { ok: true, handle: handlePath, target };
|
|
274
|
+
}
|
|
275
|
+
/** Anchored, narrow run-id shape, in `vice-broker-client.ts`'s own
|
|
276
|
+
* `REQUEST_ID_PATTERN` spirit for a validated opaque id: begins
|
|
277
|
+
* alphanumeric, then a small explicitly-listed printable set (alphanumeric,
|
|
278
|
+
* dash, underscore), no path separator, no dot ANYWHERE (a leading OR
|
|
279
|
+
* embedded dot is refused, since either would make the corresponding path
|
|
280
|
+
* segment dot-PREFIXED at its own start once joined -- e.g. a hypothetical
|
|
281
|
+
* "a.b" run id would be fine as a segment name, but this pattern refuses it
|
|
282
|
+
* anyway to keep the accepted run-id alphabet simple and auditable), and a
|
|
283
|
+
* stated length cap so a run id can never grow into something implausible. */
|
|
284
|
+
export const RUN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// Phase 36, plan 36-01 (OPC-01, OPC-04, T-36-02, T-36-03). The SLEIGH
|
|
287
|
+
// language-id shape and the extension-module file set, in the SAME
|
|
288
|
+
// "one authoritative place" discipline this module's own header states for
|
|
289
|
+
// the dot-segment rule and the per-run project location.
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
/** Anchored Ghidra language-id shape, in `RUN_ID_PATTERN`'s own spirit: a
|
|
292
|
+
* colon-separated sequence of alphanumeric-and-underscore segments (e.g.
|
|
293
|
+
* `6502:LE:16:nmos`), no path separator, no dot ANYWHERE, and a stated
|
|
294
|
+
* length cap so a caller-supplied `processor` string can never grow into
|
|
295
|
+
* something implausible. Deliberately NOT routed through
|
|
296
|
+
* `resolveWorkspacePath()` -- it is a language id, not a path (T-36-02).
|
|
297
|
+
* Compared byte-exactly and case-sensitively wherever it is used --
|
|
298
|
+
* `6502:le:16:nmos` does not match this pattern's own literal segment
|
|
299
|
+
* alphabet in a case-insensitive sense; the RegExp itself carries no `i`
|
|
300
|
+
* flag, so a caller cannot silently loosen the comparison by construction. */
|
|
301
|
+
export const LANGUAGE_ID_PATTERN = /^[A-Za-z0-9_]{1,64}(:[A-Za-z0-9_]{1,64}){1,7}$/;
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
// Phase 36, plan 36-02 (GHID-01, D-36-07). The loader-base-address shape and
|
|
304
|
+
// the import-route enum, in the SAME "one authoritative place" discipline
|
|
305
|
+
// this module's header states.
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
/** Anchored loader-base-address shape: a `0x` prefix followed by one to four
|
|
308
|
+
* LOWERCASE hex digits, anchored at both ends -- `"0X0"` (uppercase X) and
|
|
309
|
+
* `"0x0; rm -rf /"` both fail this pattern by construction, since the
|
|
310
|
+
* comparison is over the WHOLE string, not a prefix match. It is a raw argv
|
|
311
|
+
* token (never a path), so it is validated here rather than routed through
|
|
312
|
+
* `resolveWorkspacePath()` (T-36-09). */
|
|
313
|
+
export const LOADER_BASE_ADDR_PATTERN = /^0x[0-9a-f]{1,4}$/;
|
|
314
|
+
export const GHIDRA_IMPORT_ROUTES = Object.freeze(["prg", "flat64k"]);
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
// Phase 37, plan 37-08 (AUTO-07): the data-range pre-script's own fixed
|
|
317
|
+
// script name. `dataRangesPath` (below) is a wire field naming a RANGE FILE
|
|
318
|
+
// only -- never a script name -- because the script that reads it always
|
|
319
|
+
// lives beside `VolatileCarve.java`/`GhidraStructExport.java` in the SAME
|
|
320
|
+
// `scriptPath` directory every caller already supplies (see
|
|
321
|
+
// `ghidra-live.test.ts`'s own `scriptPath: "vendor/ghidra-scripts"`
|
|
322
|
+
// convention), exactly like `GHIDRA_STOCK_6502_LANGUAGE_FILES` above names a
|
|
323
|
+
// fixed file set rather than accepting one on the wire. This keeps
|
|
324
|
+
// `dataRangesPath` the ONLY new field this plan adds (D-37-33), with no
|
|
325
|
+
// second "which script" field to keep in sync.
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
/** The fixed name of plan 37-08's new pre-script, `DataRangeSeed.java` --
|
|
328
|
+
* resolved by Ghidra against the SAME `-scriptPath` directory every other
|
|
329
|
+
* script name here already resolves against. Never a wire field. */
|
|
330
|
+
export const DATA_RANGE_SEED_SCRIPT_NAME = "DataRangeSeed.java";
|
|
331
|
+
/** The route's own fixed loader base address, as a lowercase-hex string
|
|
332
|
+
* matching `LOADER_BASE_ADDR_PATTERN`. The flat-64K route bases at zero --
|
|
333
|
+
* the whole 64K address space IS the image; the `.prg` route bases at
|
|
334
|
+
* `0x801`, the C64 BASIC program start address (MEASURED, carried from
|
|
335
|
+
* `36-RESEARCH.md`'s own recorded `analyzeHeadless` invocations) -- a
|
|
336
|
+
* `.prg`'s load address is a property of the IMAGE, not of the route, which
|
|
337
|
+
* is why `loaderBaseAddr` stays a separately overridable field on the `prg`
|
|
338
|
+
* route (D-36-07) rather than being folded into this function entirely. */
|
|
339
|
+
export function importRouteBaseAddr(route) {
|
|
340
|
+
return route === "flat64k" ? "0x0" : "0x801";
|
|
341
|
+
}
|
|
342
|
+
/** The three stock 6502-processor language files `6502_nmos.slaspec` and
|
|
343
|
+
* `6502_nmos.ldefs` depend on (`@include "6502.slaspec"`, and `.ldefs`
|
|
344
|
+
* `processorspec="6502.pspec"` / `compiler spec="6502.cspec"`) -- per
|
|
345
|
+
* D-36-02, copied at INSTALL time from the host's own Ghidra installation,
|
|
346
|
+
* never committed into this repository (committing them would silently pin
|
|
347
|
+
* a Ghidra version inside this repo, the same objection already recorded
|
|
348
|
+
* against committing the compiled `.sla`). `ghidra.installExtension`
|
|
349
|
+
* (host-tool.mts) and `sleigh-compile-gate.test.ts`'s COMPILE half both copy
|
|
350
|
+
* exactly this list from `<GHIDRA_HOME>/Ghidra/Processors/6502/data/languages/`
|
|
351
|
+
* -- one shared list, never re-typed at either call site. */
|
|
352
|
+
export const GHIDRA_STOCK_6502_LANGUAGE_FILES = Object.freeze(["6502.slaspec", "6502.pspec", "6502.cspec"]);
|
|
353
|
+
/** Matches one `<language ...>` opening tag at a time. Attributes may span
|
|
354
|
+
* multiple lines (MEASURED against the real stock `6502.ldefs`), so this
|
|
355
|
+
* deliberately does not anchor to a single line -- it stops at the first
|
|
356
|
+
* unescaped `>`, which is always the tag's own close, since no attribute
|
|
357
|
+
* value in a `.ldefs` file contains a literal `>`. */
|
|
358
|
+
const LANGUAGE_ELEMENT_PATTERN = /<language\b[^>]*>/g;
|
|
359
|
+
const ID_ATTR_PATTERN = /\bid\s*=\s*"([^"]*)"/;
|
|
360
|
+
const SLAFILE_ATTR_PATTERN = /\bslafile\s*=\s*"([^"]*)"/;
|
|
361
|
+
/**
|
|
362
|
+
* Walks every module directory under `<ghidraHome>/Ghidra/Extensions/` and
|
|
363
|
+
* `<ghidraHome>/Ghidra/Processors/`, reading each one's own
|
|
364
|
+
* `data/languages/` directory for `.ldefs` files, and returns
|
|
365
|
+
* every declared `<language>` element's own `id`/`slafile` pair, plus
|
|
366
|
+
* whether that `slafile` exists on disk beside its own `.ldefs` -- a SORTED
|
|
367
|
+
* list (by `id`) so any assertion over the result is order-independent.
|
|
368
|
+
*
|
|
369
|
+
* `ghidraHome` is an explicit parameter, never read from
|
|
370
|
+
* `process.env.GHIDRA_HOME` internally, so the whole function is drivable
|
|
371
|
+
* against a synthetic directory tree with no real Ghidra installation
|
|
372
|
+
* present.
|
|
373
|
+
*
|
|
374
|
+
* A missing root (no `Extensions/` directory at all, the ordinary state of
|
|
375
|
+
* a fresh Ghidra install before any extension is ever installed) is
|
|
376
|
+
* treated as "no entries there" -- neither a refusal nor a throw. Any other
|
|
377
|
+
* filesystem error on a candidate module/language-file is likewise skipped
|
|
378
|
+
* rather than thrown: this function's contract is a plain data return, not
|
|
379
|
+
* a discriminated result.
|
|
380
|
+
*
|
|
381
|
+
* Read via a narrow anchored attribute match, never a general XML parse --
|
|
382
|
+
* this module has no parser dependency today and must not gain one for a
|
|
383
|
+
* single filesystem-read helper.
|
|
384
|
+
*
|
|
385
|
+
* This function performs no child-process call and touches nothing outside
|
|
386
|
+
* the directory it is given -- it never creates, copies, or compiles
|
|
387
|
+
* anything, preserving this module's own header invariant.
|
|
388
|
+
*/
|
|
389
|
+
export function installedLanguageIds(ghidraHome) {
|
|
390
|
+
const results = [];
|
|
391
|
+
const moduleRoots = [join(ghidraHome, "Ghidra", "Extensions"), join(ghidraHome, "Ghidra", "Processors")];
|
|
392
|
+
for (const moduleRoot of moduleRoots) {
|
|
393
|
+
let moduleNames;
|
|
394
|
+
try {
|
|
395
|
+
moduleNames = readdirSync(moduleRoot, { withFileTypes: true })
|
|
396
|
+
.filter((entry) => entry.isDirectory())
|
|
397
|
+
.map((entry) => entry.name);
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
for (const moduleName of moduleNames) {
|
|
403
|
+
const languagesDir = join(moduleRoot, moduleName, "data", "languages");
|
|
404
|
+
let fileNames;
|
|
405
|
+
try {
|
|
406
|
+
fileNames = readdirSync(languagesDir).filter((name) => name.endsWith(".ldefs"));
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
for (const fileName of fileNames) {
|
|
412
|
+
const ldefsPath = join(languagesDir, fileName);
|
|
413
|
+
let text;
|
|
414
|
+
try {
|
|
415
|
+
text = readFileSync(ldefsPath, "utf8");
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
for (const tagMatch of text.matchAll(LANGUAGE_ELEMENT_PATTERN)) {
|
|
421
|
+
const tagText = tagMatch[0];
|
|
422
|
+
const idMatch = ID_ATTR_PATTERN.exec(tagText);
|
|
423
|
+
const slafileMatch = SLAFILE_ATTR_PATTERN.exec(tagText);
|
|
424
|
+
if (!idMatch || !slafileMatch)
|
|
425
|
+
continue;
|
|
426
|
+
const id = idMatch[1];
|
|
427
|
+
const slafile = slafileMatch[1];
|
|
428
|
+
const slafileExists = existsSync(join(languagesDir, slafile));
|
|
429
|
+
results.push({ id, ldefsPath, slafile, slafileExists });
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
results.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
435
|
+
return results;
|
|
436
|
+
}
|
|
437
|
+
/** Splits `absolutePath` on the platform separator and reports the FIRST
|
|
438
|
+
* segment beginning with `.` -- checking EVERY segment, not just the leaf,
|
|
439
|
+
* per Finding 2's own discovery. Returning the offending SEGMENT (never a
|
|
440
|
+
* bare boolean) is what lets every caller's refusal message name the
|
|
441
|
+
* offender rather than merely saying "refused".
|
|
442
|
+
*
|
|
443
|
+
* The empty leading segment produced by splitting an absolute path (e.g.
|
|
444
|
+
* `"/a/b".split(sep)` -> `["", "a", "b"]`) is ignored -- it is not a real
|
|
445
|
+
* path element and does not begin with `.` in any user-meaningful sense.
|
|
446
|
+
* A doubled separator (`"a//b"`) produces an empty INTERNAL segment, which
|
|
447
|
+
* is likewise skipped: there is no element there to judge.
|
|
448
|
+
*
|
|
449
|
+
* A literal `".."` segment (a parent-directory reference) STARTS with `.`
|
|
450
|
+
* exactly like any other dot-prefixed name, so it is refused by the same
|
|
451
|
+
* check with no special-casing -- this function never resolves or
|
|
452
|
+
* normalises the path, precisely so a `".."` segment stays visible to it
|
|
453
|
+
* (path.resolve()/path.join() would silently normalise `".."` away before
|
|
454
|
+
* this function ever saw it).
|
|
455
|
+
*
|
|
456
|
+
* Never throws: `/` and `""` both resolve to no dotted segment. */
|
|
457
|
+
export function hasDotPrefixedSegment(absolutePath) {
|
|
458
|
+
if (typeof absolutePath !== "string" || absolutePath === "")
|
|
459
|
+
return { dotted: false };
|
|
460
|
+
const rawSegments = absolutePath.split(sep);
|
|
461
|
+
for (let i = 0; i < rawSegments.length; i++) {
|
|
462
|
+
const segment = rawSegments[i];
|
|
463
|
+
if (segment === "")
|
|
464
|
+
continue; // the absolute-path leading empty segment, or a doubled separator
|
|
465
|
+
if (segment.startsWith("."))
|
|
466
|
+
return { dotted: true, segment };
|
|
467
|
+
}
|
|
468
|
+
return { dotted: false };
|
|
469
|
+
}
|
|
470
|
+
function isPlainObject(value) {
|
|
471
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
472
|
+
}
|
|
473
|
+
function describe(value) {
|
|
474
|
+
try {
|
|
475
|
+
const json = JSON.stringify(value);
|
|
476
|
+
return json === undefined ? String(value) : json;
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
return String(value);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
// ---------------------------------------------------------------------------
|
|
483
|
+
// resolveGhidraProject -- the per-run project location (SEAM-04). Mirrors
|
|
484
|
+
// broker-control.mts's normaliseLaunchProfile()/host-tool.mts's
|
|
485
|
+
// normaliseHostToolRequest() discipline: narrow at one site, refuse unknown
|
|
486
|
+
// keys BY NAME, never coerce.
|
|
487
|
+
// ---------------------------------------------------------------------------
|
|
488
|
+
const RESOLVE_GHIDRA_PROJECT_KEYS = Object.freeze(["repoRoot", "runId"]);
|
|
489
|
+
const RESOLVE_GHIDRA_PROJECT_SHAPE = `an object with exactly the keys ${RESOLVE_GHIDRA_PROJECT_KEYS.join("/")}, both non-empty strings`;
|
|
490
|
+
/** THIS IS THE ONE PLACE a Ghidra run's project location is computed. Never
|
|
491
|
+
* throws; answers a discriminated result naming the offending value, key,
|
|
492
|
+
* or path segment. Refusal order: shape/keys, then `repoRoot` type, then
|
|
493
|
+
* `runId` type and pattern, then the dot-segment check over the computed
|
|
494
|
+
* ABSOLUTE `projectLocation` (which, since it is built by joining `repoRoot`
|
|
495
|
+
* onto itself, also catches a dot-prefixed `repoRoot` -- e.g. a caller that
|
|
496
|
+
* mistakenly passed `.vice-supervisor/` or `.planning/` as `repoRoot`),
|
|
497
|
+
* then `ensureGhidraRunsHandle()` as an idempotent precondition (a refusal
|
|
498
|
+
* here propagates straight out, before any reuse check and before the
|
|
499
|
+
* reservation `mkdirSync` below ever runs -- this order is what keeps a
|
|
500
|
+
* missing/wrong handle from silently materialising a second root), then the
|
|
501
|
+
* already-exists (no-reuse) idempotency check. */
|
|
502
|
+
export function resolveGhidraProject(input) {
|
|
503
|
+
if (!isPlainObject(input)) {
|
|
504
|
+
return { ok: false, message: `resolveGhidraProject input must be ${RESOLVE_GHIDRA_PROJECT_SHAPE}; got ${describe(input)}` };
|
|
505
|
+
}
|
|
506
|
+
const unknownKeys = Object.keys(input).filter((key) => !RESOLVE_GHIDRA_PROJECT_KEYS.includes(key));
|
|
507
|
+
if (unknownKeys.length > 0) {
|
|
508
|
+
return {
|
|
509
|
+
ok: false,
|
|
510
|
+
message: `resolveGhidraProject input has unknown key(s) ${unknownKeys.join(", ")}; accepted shape is ${RESOLVE_GHIDRA_PROJECT_SHAPE}`,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
const repoRoot = input.repoRoot;
|
|
514
|
+
if (typeof repoRoot !== "string" || repoRoot === "") {
|
|
515
|
+
return { ok: false, message: `resolveGhidraProject requires a non-empty string "repoRoot"; got ${describe(repoRoot)}` };
|
|
516
|
+
}
|
|
517
|
+
const runId = input.runId;
|
|
518
|
+
if (typeof runId !== "string" || runId === "") {
|
|
519
|
+
return { ok: false, message: `resolveGhidraProject requires a non-empty string "runId"; got ${describe(runId)}` };
|
|
520
|
+
}
|
|
521
|
+
if (!RUN_ID_PATTERN.test(runId)) {
|
|
522
|
+
return {
|
|
523
|
+
ok: false,
|
|
524
|
+
message: `resolveGhidraProject "runId" must match ${RUN_ID_PATTERN.source} (alphanumeric-first, alphanumeric/dash/underscore only, no separator, no dot, length-capped); got ${describe(runId)}`,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
const runsRoot = ghidraRunsRoot(repoRoot);
|
|
528
|
+
const projectLocation = join(runsRoot, runId);
|
|
529
|
+
const projectName = runId;
|
|
530
|
+
const dotted = hasDotPrefixedSegment(projectLocation);
|
|
531
|
+
if (dotted.dotted) {
|
|
532
|
+
return {
|
|
533
|
+
ok: false,
|
|
534
|
+
message: `resolveGhidraProject refuses a project location containing a dot-prefixed path element ("${dotted.segment}"): ${DOT_SEGMENT_REFUSAL}; computed location was ${projectLocation}`,
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
const handleResult = ensureGhidraRunsHandle(repoRoot);
|
|
538
|
+
if (!handleResult.ok) {
|
|
539
|
+
return { ok: false, message: handleResult.message };
|
|
540
|
+
}
|
|
541
|
+
if (existsSync(projectLocation)) {
|
|
542
|
+
return {
|
|
543
|
+
ok: false,
|
|
544
|
+
message: `resolveGhidraProject refuses to reuse an existing run directory (${projectLocation}): ` +
|
|
545
|
+
`a Ghidra project directory is never reused across runs, because reuse is exactly what makes ` +
|
|
546
|
+
`Ghidra's single-writer project lock reachable again -- choose a different runId`,
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
// CREATE the directory here, as the LAST step of a successful resolution
|
|
550
|
+
// (never on a refusal above). Measured live this plan (Task 3): real
|
|
551
|
+
// Ghidra 12.1.3 does NOT create the leaf project directory itself --
|
|
552
|
+
// `analyzeHeadless` against a clean, well-formed, not-yet-existing
|
|
553
|
+
// location fails with `java.io.FileNotFoundException: Directory not
|
|
554
|
+
// found` at `DefaultProjectManager.createProject()`. Creating it HERE
|
|
555
|
+
// (rather than leaving it to host-tool.mts or to a caller) is what makes
|
|
556
|
+
// this function a genuine RESERVATION: the directory exists the instant
|
|
557
|
+
// `ok: true` is returned, so the existsSync() check above is what a
|
|
558
|
+
// second call under the SAME run id will see -- there is no window where
|
|
559
|
+
// two concurrent callers could both observe an absent directory.
|
|
560
|
+
try {
|
|
561
|
+
mkdirSync(projectLocation, { recursive: true });
|
|
562
|
+
}
|
|
563
|
+
catch (e) {
|
|
564
|
+
return {
|
|
565
|
+
ok: false,
|
|
566
|
+
message: `resolveGhidraProject failed to create the run directory (${projectLocation}): ${e instanceof Error ? e.message : String(e)}`,
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
return { ok: true, runsRoot, projectLocation, projectName };
|
|
570
|
+
}
|
|
571
|
+
// ---------------------------------------------------------------------------
|
|
572
|
+
// buildAnalyzeHeadlessArgv -- server-side argv construction (mirrors
|
|
573
|
+
// host-tool.mts's own buildHostToolArgv() discipline: typed fields only,
|
|
574
|
+
// re-checks the dot-segment rule independently so it holds even for a
|
|
575
|
+
// caller that bypassed resolveGhidraProject()).
|
|
576
|
+
// ---------------------------------------------------------------------------
|
|
577
|
+
const BUILD_ANALYZE_HEADLESS_ARGV_KEYS = Object.freeze([
|
|
578
|
+
"projectLocation",
|
|
579
|
+
"projectName",
|
|
580
|
+
"importPath",
|
|
581
|
+
"processor",
|
|
582
|
+
"loaderBaseAddr",
|
|
583
|
+
"noanalysis",
|
|
584
|
+
"scriptPath",
|
|
585
|
+
"preScript",
|
|
586
|
+
"entrypointsPath",
|
|
587
|
+
"postScript",
|
|
588
|
+
"exportPath",
|
|
589
|
+
"expectedClassificationLines",
|
|
590
|
+
// Phase 37, plan 37-08 (AUTO-07, D-37-33): the ONE new key this plan adds.
|
|
591
|
+
"dataRangesPath",
|
|
592
|
+
]);
|
|
593
|
+
const BUILD_ANALYZE_HEADLESS_ARGV_SHAPE = `an object with keys ${BUILD_ANALYZE_HEADLESS_ARGV_KEYS.join("/")} ` +
|
|
594
|
+
`("projectLocation"/"projectName"/"importPath"/"processor"/"loaderBaseAddr" required non-empty strings, ` +
|
|
595
|
+
`"noanalysis" an optional boolean, "scriptPath"/"preScript"/"entrypointsPath"/"postScript"/"exportPath"/` +
|
|
596
|
+
`"dataRangesPath" optional non-empty strings, "expectedClassificationLines" an optional non-negative integer)`;
|
|
597
|
+
/** Emits `[projectLocation, projectName, "-import", importPath, "-processor",
|
|
598
|
+
* processor, "-deleteProject", ...optional pre/post script flags]`.
|
|
599
|
+
*
|
|
600
|
+
* `-deleteProject` only applies on the `-import` path (never on `-process`)
|
|
601
|
+
* -- the local `analyzeHeadlessREADME.md` (Ghidra 12.1.3) states: "the
|
|
602
|
+
* Ghidra project will be deleted after scripts and/or analysis have
|
|
603
|
+
* completed (only applies if the project has been created in the current
|
|
604
|
+
* session with `-import`; existing projects are never deleted)." This is
|
|
605
|
+
* exactly why the per-run directory (resolveGhidraProject()'s own refusal
|
|
606
|
+
* to reuse an existing run directory) is the PRIMARY mechanism that makes
|
|
607
|
+
* the single-writer lock unreachable, and `-deleteProject` is a SECONDARY
|
|
608
|
+
* cleanup on top of it, never a substitute for it. */
|
|
609
|
+
export function buildAnalyzeHeadlessArgv(input) {
|
|
610
|
+
if (!isPlainObject(input)) {
|
|
611
|
+
return { ok: false, message: `buildAnalyzeHeadlessArgv input must be ${BUILD_ANALYZE_HEADLESS_ARGV_SHAPE}; got ${describe(input)}` };
|
|
612
|
+
}
|
|
613
|
+
const unknownKeys = Object.keys(input).filter((key) => !BUILD_ANALYZE_HEADLESS_ARGV_KEYS.includes(key));
|
|
614
|
+
if (unknownKeys.length > 0) {
|
|
615
|
+
return {
|
|
616
|
+
ok: false,
|
|
617
|
+
message: `buildAnalyzeHeadlessArgv input has unknown key(s) ${unknownKeys.join(", ")}; accepted shape is ${BUILD_ANALYZE_HEADLESS_ARGV_SHAPE}`,
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
const { projectLocation, projectName, importPath, processor, loaderBaseAddr, noanalysis, scriptPath, preScript, entrypointsPath, postScript, exportPath, expectedClassificationLines, dataRangesPath } = input;
|
|
621
|
+
for (const [key, value] of [
|
|
622
|
+
["projectLocation", projectLocation],
|
|
623
|
+
["projectName", projectName],
|
|
624
|
+
["importPath", importPath],
|
|
625
|
+
["processor", processor],
|
|
626
|
+
["loaderBaseAddr", loaderBaseAddr],
|
|
627
|
+
]) {
|
|
628
|
+
if (typeof value !== "string" || value === "") {
|
|
629
|
+
return { ok: false, message: `buildAnalyzeHeadlessArgv requires a non-empty string "${key}"; got ${describe(value)}` };
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
// Phase 36, plan 36-01 (T-36-02): an INDEPENDENT second-layer check --
|
|
633
|
+
// re-validated against LANGUAGE_ID_PATTERN here, so the rule holds even
|
|
634
|
+
// for a caller that constructed this field itself and bypassed
|
|
635
|
+
// host-tool.mts's own normaliseHostToolRequest() entirely. Mirrors the
|
|
636
|
+
// dot-segment re-check below and the parent-segment re-check just after
|
|
637
|
+
// it -- same second-layer discipline, third field.
|
|
638
|
+
if (!LANGUAGE_ID_PATTERN.test(processor)) {
|
|
639
|
+
return {
|
|
640
|
+
ok: false,
|
|
641
|
+
message: `buildAnalyzeHeadlessArgv "processor" must match ${LANGUAGE_ID_PATTERN.source}; got ${describe(processor)}`,
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
// Phase 36, plan 36-02 (D-36-07, T-36-09): the SAME independent
|
|
645
|
+
// second-layer discipline, fourth field -- so the rule holds even for a
|
|
646
|
+
// caller that constructed `loaderBaseAddr` itself and bypassed
|
|
647
|
+
// host-tool.mts's own route-conflict check entirely.
|
|
648
|
+
if (!LOADER_BASE_ADDR_PATTERN.test(loaderBaseAddr)) {
|
|
649
|
+
return {
|
|
650
|
+
ok: false,
|
|
651
|
+
message: `buildAnalyzeHeadlessArgv "loaderBaseAddr" must match ${LOADER_BASE_ADDR_PATTERN.source}; got ${describe(loaderBaseAddr)}`,
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
if (noanalysis !== undefined && typeof noanalysis !== "boolean") {
|
|
655
|
+
return { ok: false, message: `buildAnalyzeHeadlessArgv "noanalysis" must be a boolean or absent; got ${describe(noanalysis)}` };
|
|
656
|
+
}
|
|
657
|
+
if (expectedClassificationLines !== undefined && (typeof expectedClassificationLines !== "number" || !Number.isInteger(expectedClassificationLines) || expectedClassificationLines < 0)) {
|
|
658
|
+
return {
|
|
659
|
+
ok: false,
|
|
660
|
+
message: `buildAnalyzeHeadlessArgv "expectedClassificationLines" must be a non-negative integer or absent; got ${describe(expectedClassificationLines)}`,
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
for (const [key, value] of [
|
|
664
|
+
["scriptPath", scriptPath],
|
|
665
|
+
["preScript", preScript],
|
|
666
|
+
["entrypointsPath", entrypointsPath],
|
|
667
|
+
["postScript", postScript],
|
|
668
|
+
["exportPath", exportPath],
|
|
669
|
+
// Phase 37, plan 37-08 (AUTO-07): the SAME "optional non-empty string"
|
|
670
|
+
// validation every other script-adjacent path field already gets.
|
|
671
|
+
["dataRangesPath", dataRangesPath],
|
|
672
|
+
]) {
|
|
673
|
+
if (value !== undefined && (typeof value !== "string" || value === "")) {
|
|
674
|
+
return { ok: false, message: `buildAnalyzeHeadlessArgv "${key}" must be a non-empty string or absent; got ${describe(value)}` };
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
// 34-07 (CR-02) / 36-02: an INDEPENDENT second-layer check -- refuse a
|
|
678
|
+
// preScript/postScript/scriptPath/entrypointsPath/exportPath containing a
|
|
679
|
+
// parent-directory path segment, even for a caller that constructed
|
|
680
|
+
// these fields itself and bypassed host-tool.mts's own
|
|
681
|
+
// resolveWorkspacePath() entirely. Mirrors the dot-segment re-check just
|
|
682
|
+
// below: same per-SEGMENT splitting approach (never a substring test), so
|
|
683
|
+
// a name that merely CONTAINS two dots (e.g. "..foo.java") is not
|
|
684
|
+
// misjudged -- only an exact ".." segment is a parent-directory
|
|
685
|
+
// reference. Does NOT require absoluteness: a bare Ghidra script name
|
|
686
|
+
// ("Pre.java") is Ghidra's own documented form for these flags and must
|
|
687
|
+
// stay accepted (ghidra-project.test.ts's own pre-existing case).
|
|
688
|
+
for (const [key, value] of [
|
|
689
|
+
["preScript", preScript],
|
|
690
|
+
["postScript", postScript],
|
|
691
|
+
["scriptPath", scriptPath],
|
|
692
|
+
["entrypointsPath", entrypointsPath],
|
|
693
|
+
["exportPath", exportPath],
|
|
694
|
+
// Phase 37, plan 37-08 (AUTO-07): the SAME independent parent-segment
|
|
695
|
+
// re-check every other script-adjacent path field already gets.
|
|
696
|
+
["dataRangesPath", dataRangesPath],
|
|
697
|
+
]) {
|
|
698
|
+
if (typeof value !== "string")
|
|
699
|
+
continue;
|
|
700
|
+
const hasParentSegment = value.split(sep).some((segment) => segment === "..");
|
|
701
|
+
if (hasParentSegment) {
|
|
702
|
+
return {
|
|
703
|
+
ok: false,
|
|
704
|
+
message: `buildAnalyzeHeadlessArgv refuses a "${key}" containing a parent-directory path segment: a script ` +
|
|
705
|
+
`argument is either a bare Ghidra script name or a path already bounded inside the workspace; got ${describe(value)}`,
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
// Phase 36, plan 36-02: "a script argument with no script" is refused BY
|
|
710
|
+
// NAME rather than silently dropped -- a dropped argument is how a run
|
|
711
|
+
// reports success having asserted nothing (must_haves.prohibitions).
|
|
712
|
+
// Independent second-layer check, mirroring every other rule above.
|
|
713
|
+
if (typeof entrypointsPath === "string" && typeof preScript !== "string") {
|
|
714
|
+
return {
|
|
715
|
+
ok: false,
|
|
716
|
+
message: `buildAnalyzeHeadlessArgv refuses "entrypointsPath" without "preScript": a script argument with no script to receive it`,
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
if (typeof postScript !== "string" && (typeof exportPath === "string" || expectedClassificationLines !== undefined)) {
|
|
720
|
+
return {
|
|
721
|
+
ok: false,
|
|
722
|
+
message: `buildAnalyzeHeadlessArgv refuses "exportPath"/"expectedClassificationLines" without "postScript": a script argument with no script to receive it`,
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
if (expectedClassificationLines !== undefined && typeof exportPath !== "string") {
|
|
726
|
+
return {
|
|
727
|
+
ok: false,
|
|
728
|
+
message: `buildAnalyzeHeadlessArgv refuses "expectedClassificationLines" without "exportPath": it is the export script's own second argument, positioned after the export path`,
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
// Re-run the dot-segment check independently of resolveGhidraProject() --
|
|
732
|
+
// so the rule holds even for a caller that constructed `projectLocation`
|
|
733
|
+
// itself and skipped the resolver entirely (T-34-14).
|
|
734
|
+
const dotted = hasDotPrefixedSegment(projectLocation);
|
|
735
|
+
if (dotted.dotted) {
|
|
736
|
+
return {
|
|
737
|
+
ok: false,
|
|
738
|
+
message: `buildAnalyzeHeadlessArgv refuses a dot-prefixed project location ("${dotted.segment}"): ${DOT_SEGMENT_REFUSAL}; got ${describe(projectLocation)}`,
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
// Phase 36, plan 36-01/36-02 (OPC-04, D-36-07): fixed-order argv, each
|
|
742
|
+
// flag and its value as SEPARATE array entries, never string-concatenated.
|
|
743
|
+
// "-loader BinaryLoader" is a fixed literal (never a wire field, D-36-07);
|
|
744
|
+
// "-loader-baseAddr" is ALWAYS emitted, since host-tool.mts always
|
|
745
|
+
// supplies a value (either the caller's own or the route's own default).
|
|
746
|
+
const argv = [
|
|
747
|
+
projectLocation,
|
|
748
|
+
projectName,
|
|
749
|
+
"-import",
|
|
750
|
+
importPath,
|
|
751
|
+
"-processor",
|
|
752
|
+
processor,
|
|
753
|
+
"-loader",
|
|
754
|
+
"BinaryLoader",
|
|
755
|
+
"-loader-baseAddr",
|
|
756
|
+
loaderBaseAddr,
|
|
757
|
+
];
|
|
758
|
+
if (noanalysis === true)
|
|
759
|
+
argv.push("-noanalysis");
|
|
760
|
+
if (typeof scriptPath === "string")
|
|
761
|
+
argv.push("-scriptPath", scriptPath);
|
|
762
|
+
// Phase 37, plan 37-08 (AUTO-07, D-37-33): DataRangeSeed.java's own
|
|
763
|
+
// `-preScript` pair is emitted FIRST, before the caller's own `preScript`
|
|
764
|
+
// (VolatileCarve.java) -- `analyzeHeadless` runs `-preScript` entries in
|
|
765
|
+
// argv order (analyzeHeadlessREADME.md: "Using Multiple Scripts"), and
|
|
766
|
+
// VolatileCarve.java's own `run()` calls `analyzeAll()` itself at the end
|
|
767
|
+
// of ITS run -- so the data ranges must already be marked as data before
|
|
768
|
+
// that call happens, or the code-discovery analysis this whole feedback
|
|
769
|
+
// exists to suppress would already have run over them. Independent of
|
|
770
|
+
// whether a `preScript` is present at all: `dataRangesPath` needs no
|
|
771
|
+
// OTHER script to be useful (unlike `entrypointsPath`, which is
|
|
772
|
+
// VolatileCarve.java's own positional argument).
|
|
773
|
+
if (typeof dataRangesPath === "string")
|
|
774
|
+
argv.push("-preScript", DATA_RANGE_SEED_SCRIPT_NAME, dataRangesPath);
|
|
775
|
+
if (typeof preScript === "string") {
|
|
776
|
+
argv.push("-preScript", preScript);
|
|
777
|
+
if (typeof entrypointsPath === "string")
|
|
778
|
+
argv.push(entrypointsPath);
|
|
779
|
+
}
|
|
780
|
+
if (typeof postScript === "string") {
|
|
781
|
+
argv.push("-postScript", postScript);
|
|
782
|
+
if (typeof exportPath === "string") {
|
|
783
|
+
argv.push(exportPath);
|
|
784
|
+
if (typeof expectedClassificationLines === "number")
|
|
785
|
+
argv.push(String(expectedClassificationLines));
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
argv.push("-deleteProject");
|
|
789
|
+
return { ok: true, argv };
|
|
790
|
+
}
|