@henols/c64-re-tools 0.2.1 → 0.2.2
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 +1 -1
- package/THIRD-PARTY-NOTICES.md +26 -0
- package/bin/cli.mjs +18 -7
- package/package.json +6 -4
- package/skills/acme-build/SKILL.md +51 -17
- package/skills/c64-memory-mapping/SKILL.md +409 -20
- package/skills/c64-program-recon/SKILL.md +437 -86
- package/skills/c64-program-recon/references/reconstruction.md +10 -4
- package/skills/c64-program-recon/references/tool-selection.md +2 -2
- package/skills/c64-program-recon/scripts/packer-finding.mjs +631 -0
- package/skills/c64-program-recon/templates/memory-map.template.md +25 -11
- package/skills/c64-provenance-diff/SKILL.md +3 -3
- package/skills/c64-provenance-diff/scripts/diff-images.mjs +3 -0
- package/skills/c64-provenance-diff/scripts/recovery-schema.mjs +14 -4
- package/skills/c64-ram-capture/RELEASES.json.example +17 -0
- package/skills/c64-ram-capture/SKILL.md +35 -2
- package/skills/c64-ram-capture/scripts/project-paths.mjs +1 -1
- package/skills/c64-ram-capture/scripts/watch-loads.mjs +6 -0
- package/skills/routine-queue-walker/SKILL.md +273 -0
- package/skills/vice-wedge-triage/SKILL.md +8 -8
- package/skills/c64-provenance-diff/scripts/diff-images.test.mjs +0 -665
- package/skills/c64-ram-capture/scripts/d64-parse.test.mjs +0 -243
- package/skills/c64-ram-capture/scripts/dump-artifacts.test.mjs +0 -133
- package/skills/c64-ram-capture/scripts/test-corpus.mjs +0 -75
- package/skills/c64-ram-capture/scripts/watch-loads.test.mjs +0 -339
|
@@ -18,7 +18,7 @@ usage, not measured). Individual rows that have since been exercised live are ma
|
|
|
18
18
|
| Whole-chip SID state without the read hazards | `vice_sid_get_state` (**requires the fork** — SID `$D400-$D418` is write-only in hardware and the binary monitor has no SID command; unrecoverable on stock) |
|
|
19
19
|
| Decode sprite data | `vice_sprite_get` / `vice_sprite_inspect` (**both backends**) |
|
|
20
20
|
| Find a known byte pattern | `vice_memory_search` (**both backends**) |
|
|
21
|
-
| Carry labels across sessions | `vice_symbols_load` / `vice_symbols_lookup` (**both backends**) — ACME `--vicelabels` and
|
|
21
|
+
| Carry labels across sessions | `vice_symbols_load` / `vice_symbols_lookup` (**both backends**) — ACME `--vicelabels` emits the format they consume. The annotation store's own export into that format is **withdrawn as of 2026-08-29, and no phase currently owns its return** — an earlier forecast naming a numbered phase for it is superseded |
|
|
22
22
|
| Is the machine wedged, or did it stop itself? | `vice_diagnose` — five-state verdict with its evidence (the two backends' verdict sets differ by one; see `docs/stock-vice-parity.md` D-03). **Reachable and proxy-intercepted as of 2026-08-04** (verified live). Triage tree: `vice-wedge-triage` |
|
|
23
23
|
| Replace a wedged instance | `vice_recycle` — destructive, requires a `reason`, and that reason is written into `.planning/incidents/` **before** anything is killed. The reason *is* the evidence record |
|
|
24
24
|
| Read the restart epoch | **No tool does.** The proxy compares it around every forwarded call and raises drift itself; a value comes from that error or from `vice_diagnose` |
|
|
@@ -30,7 +30,7 @@ usage, not measured). Individual rows that have since been exercised live are ma
|
|
|
30
30
|
| What does address X mean? | the `c64-memory-mapping` skill — `node … lookup '$D018'`. **Do not restate its tables.** |
|
|
31
31
|
| Is this byte original or cracker-changed? | the `c64-provenance-diff` skill |
|
|
32
32
|
| A verified 64K image, or comparing two captures | the `c64-ram-capture` skill |
|
|
33
|
-
|
|
|
33
|
+
| Whole-program static disassembly with code/data separation | **`anno export-asm`** — withdrawn 2026-08-29, returned 2026-08-31, settled by assembling the output with a real ACME and diffing the bytes against the input. That oracle is test-only, so the verb itself writes source and runs no assembler. For a single routine, read one explicit range at a time with `anno_read_region` / `anno_disassemble` (4096-byte cap per call, refused rather than truncated above it) and record what you verified with `anno_set_data_type` |
|
|
34
34
|
|
|
35
35
|
## Three traps in this table
|
|
36
36
|
|
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// packer-finding.mjs -- the ONE place this project answers "which packer was
|
|
3
|
+
// used on this binary", as a project-owned recon finding with an ordered
|
|
4
|
+
// oracle chain and a hard, reasoned unknown (SURF-03).
|
|
5
|
+
//
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// WHY THIS FILE EXISTS
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// DATED PROVENANCE (2026-08-29): the retired static analyser this project once
|
|
10
|
+
// rented -- the external analyser, pinned at 0.9.20 -- computed packer identity on
|
|
11
|
+
// EVERY load and then threw it away before it reached any machine-readable
|
|
12
|
+
// surface. That analyser is GONE from this repository; nothing below calls it,
|
|
13
|
+
// and this paragraph is history in the past tense, not a route. It is kept
|
|
14
|
+
// because it is the whole reason this file exists: without it, the obvious
|
|
15
|
+
// next step is to go looking for the packer-name call that was already proven
|
|
16
|
+
// not to be there. At the pin, that absence was established four independent
|
|
17
|
+
// ways, each read at the pin:
|
|
18
|
+
//
|
|
19
|
+
// (a) the curated binary-info tool emits a fixed seven-field object
|
|
20
|
+
// (origin, size, system, filename, description, an illegal-opcode hint
|
|
21
|
+
// and entropy) built from a static literal -- a packed input cannot
|
|
22
|
+
// change the field set;
|
|
23
|
+
// (b) the unpack result carries no name field at all: it returns data,
|
|
24
|
+
// start/end addresses, an entry point, a dependency address and an
|
|
25
|
+
// instruction count, and the one place the detected packer is consulted
|
|
26
|
+
// applies a memory patch and feeds a progress callback the MCP handler
|
|
27
|
+
// passes nothing to;
|
|
28
|
+
// (c) the loaded-project value that DOES hold a detected packer name is an
|
|
29
|
+
// in-memory return value only -- it is absent from the serialised
|
|
30
|
+
// project shape, so saving and re-reading the project file yields
|
|
31
|
+
// nothing;
|
|
32
|
+
// (d) every consumer of the name lives in the terminal-UI crate. There are
|
|
33
|
+
// zero non-UI, non-internal consumers, and the command line has no flag
|
|
34
|
+
// that reports file info.
|
|
35
|
+
//
|
|
36
|
+
// So there was no read-only route to a packer name in that analyser, and there
|
|
37
|
+
// is none on the surface that replaced it either: the annotation store holds
|
|
38
|
+
// annotations, the derived reads decode instructions, and neither answers
|
|
39
|
+
// "which packer". The two routes that would produce a name -- copying that
|
|
40
|
+
// upstream project's signature table (or transcribing its bytes into search
|
|
41
|
+
// patterns), and inventing a tool name on this project's own surface that no
|
|
42
|
+
// verb actually implements -- are both refused: the first is the copy this
|
|
43
|
+
// milestone exists to avoid, and the second would be a fabricated tool that
|
|
44
|
+
// this repository's own honesty gates would then treat as legitimate. What is
|
|
45
|
+
// left, and what this file is, is a project-owned finding with an EXTERNAL
|
|
46
|
+
// oracle and an explicit unknown.
|
|
47
|
+
//
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// WHAT THIS IS THE ONE AUTHORITATIVE PLACE FOR
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// - the ordered oracle chain (external identifier, then a packedness-only
|
|
52
|
+
// entropy gate, then an explicit unknown) and its first-hit-wins rule;
|
|
53
|
+
// - the finding's response shape and its four-verdict vocabulary
|
|
54
|
+
// (`PACKER_VERDICTS`);
|
|
55
|
+
// - the external identifier's probe (`probeUnp64`) and the defensive parser
|
|
56
|
+
// for its standard output (`parseUnp64Stdout`).
|
|
57
|
+
//
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// WHAT NOT TO DO -- the four rules, each enforced structurally below
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// 1. NEVER write the `packer` field from anything but an oracle's own
|
|
62
|
+
// verbatim output. Not from entropy, not from a decompression or
|
|
63
|
+
// dependency address, not from a byte pattern. There is exactly ONE
|
|
64
|
+
// assignment of that field in this module, inside `identifiedByOracle()`
|
|
65
|
+
// below, and it refuses a value the oracle did not supply.
|
|
66
|
+
// 2. NEVER return the high confidence level off the oracle route. The
|
|
67
|
+
// entropy route is MEDIUM and answers packedness only; the no-route case
|
|
68
|
+
// is LOW and answers `unknown`. `CONFIDENCE_HIGH` is ASSIGNED at exactly
|
|
69
|
+
// one site in this file, in the oracle branch, and the colocated test
|
|
70
|
+
// counts that site at source level.
|
|
71
|
+
// 3. NEVER return the `unknown` verdict with an empty reason. A silent null
|
|
72
|
+
// with nothing said about it is the failure mode this whole file exists
|
|
73
|
+
// to prevent, so `unknownFinding()` throws rather than build one. More
|
|
74
|
+
// generally: every finding whose `packer` is null states, in
|
|
75
|
+
// `unavailableReason`, why no name is being reported.
|
|
76
|
+
// 4. NEVER add vocabulary beyond the four verdicts. No fractional rating, no
|
|
77
|
+
// ratio, no hedged phrasing, no named-but-unconfirmed packer. A reader
|
|
78
|
+
// must get either a name an oracle stated or an explicit unknown, and
|
|
79
|
+
// nothing in between.
|
|
80
|
+
//
|
|
81
|
+
// Also: NEVER invoke the oracle through a command interpreter. Every child
|
|
82
|
+
// process below is launched with an argument ARRAY and an explicitly disabled
|
|
83
|
+
// interpreter, and a configured oracle path that does not exist on disk is
|
|
84
|
+
// treated as oracle-absent rather than being placed into any command anywhere
|
|
85
|
+
// (T-19-18). The oracle's standard output reaches exactly one field, through
|
|
86
|
+
// one bounded parser that evaluates nothing (T-19-19).
|
|
87
|
+
//
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// STATUS OF THE ORACLE BRANCH (recorded, deliberate)
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// The env-var convention below (`UNP64`, then `UNP64_PATH`) is the same one
|
|
92
|
+
// upstream's own comparison harness uses, and is verified. The SHAPE of the
|
|
93
|
+
// identifier's standard output is NOT verified here -- the tool was not
|
|
94
|
+
// installed on the machine where this was written, so `parseUnp64Stdout()` is
|
|
95
|
+
// written defensively and its accepted marker set is a stated assumption, not
|
|
96
|
+
// a measurement. Installing the identifier and running it against a genuinely
|
|
97
|
+
// packed fixture is the experiment that would settle it; until then the
|
|
98
|
+
// oracle-route test SKIPS with a visible reason and never reads as a pass.
|
|
99
|
+
import { spawnSync } from "node:child_process";
|
|
100
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
101
|
+
import { tmpdir } from "node:os";
|
|
102
|
+
import { join } from "node:path";
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Vocabulary. Exactly four verdicts, frozen. Rule 4.
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The complete verdict vocabulary. Nothing else may ever appear in a
|
|
110
|
+
* finding's `verdict` field.
|
|
111
|
+
*
|
|
112
|
+
* identified -- an oracle stated a name, verbatim.
|
|
113
|
+
* packed-unidentified -- the entropy gate places the bytes at or above its
|
|
114
|
+
* packedness threshold, and NO oracle named the
|
|
115
|
+
* packer. Packedness, never identity.
|
|
116
|
+
* unpacked -- the entropy gate puts the bytes below its
|
|
117
|
+
* threshold. Still not an identity claim.
|
|
118
|
+
* unknown -- no route produced an answer. Always carries a
|
|
119
|
+
* reason.
|
|
120
|
+
*/
|
|
121
|
+
export const PACKER_VERDICTS = Object.freeze(["identified", "packed-unidentified", "unpacked", "unknown"]);
|
|
122
|
+
|
|
123
|
+
const VERDICT_IDENTIFIED = "identified";
|
|
124
|
+
const VERDICT_PACKED_UNIDENTIFIED = "packed-unidentified";
|
|
125
|
+
const VERDICT_UNPACKED = "unpacked";
|
|
126
|
+
const VERDICT_UNKNOWN = "unknown";
|
|
127
|
+
|
|
128
|
+
const CONFIDENCE_HIGH = "HIGH";
|
|
129
|
+
const CONFIDENCE_MEDIUM = "MEDIUM";
|
|
130
|
+
const CONFIDENCE_LOW = "LOW";
|
|
131
|
+
|
|
132
|
+
const ROUTE_ORACLE = "unp64";
|
|
133
|
+
const ROUTE_ENTROPY = "entropy-only";
|
|
134
|
+
const ROUTE_NONE = "none";
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The packedness threshold, taken from the curated binary-info tool's own
|
|
138
|
+
* description ("values higher than 7.5 suggest the binary might be
|
|
139
|
+
* compressed") rather than restated from a second source. Shannon entropy
|
|
140
|
+
* over bytes runs 0.0 to 8.0.
|
|
141
|
+
*
|
|
142
|
+
* This number gates PACKEDNESS ONLY. It can never reach the `packer` field --
|
|
143
|
+
* see rule 1.
|
|
144
|
+
*/
|
|
145
|
+
export const PACKED_ENTROPY_THRESHOLD = 7.5;
|
|
146
|
+
|
|
147
|
+
/** Default command name when neither environment variable is set. */
|
|
148
|
+
const DEFAULT_ORACLE_COMMAND = "unp64";
|
|
149
|
+
|
|
150
|
+
/** The two environment variables the external identifier is located from, in
|
|
151
|
+
* this order -- the same convention upstream's own comparison harness uses. */
|
|
152
|
+
const ORACLE_ENV_VARS = Object.freeze(["UNP64", "UNP64_PATH"]);
|
|
153
|
+
|
|
154
|
+
/** The opt-in variable that turns an absent oracle from an expected skip into
|
|
155
|
+
* a hard failure, by the established `VICE_REQUIRE_*` precedent. */
|
|
156
|
+
export const REQUIRE_ORACLE_ENV_VAR = "VICE_REQUIRE_UNP64";
|
|
157
|
+
|
|
158
|
+
/** Every child process here is bounded. A hung identifier is treated exactly
|
|
159
|
+
* like an absent one (T-19-23). */
|
|
160
|
+
const ORACLE_TIMEOUT_MS = 20_000;
|
|
161
|
+
|
|
162
|
+
/** Hard cap on how much of the oracle's standard output the parser will even
|
|
163
|
+
* look at. Longer than this is rejected outright rather than scanned
|
|
164
|
+
* (T-19-19). */
|
|
165
|
+
export const MAX_ORACLE_STDOUT_BYTES = 64 * 1024;
|
|
166
|
+
|
|
167
|
+
/** Hard cap on the length of a parsed packer name. */
|
|
168
|
+
export const MAX_PACKER_NAME_LENGTH = 64;
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The accepted shape of a parsed name: begins alphanumeric, then a small,
|
|
172
|
+
* explicitly listed printable set. Deliberately narrow -- the parsed value's
|
|
173
|
+
* only destination is a report field, and narrowing it here means a hostile
|
|
174
|
+
* standard output cannot smuggle control characters or markup into a document
|
|
175
|
+
* a human later reads.
|
|
176
|
+
*/
|
|
177
|
+
const PACKER_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9 ._/+-]*$/;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Line markers the parser accepts, each anchored at the start of a trimmed
|
|
181
|
+
* line and each followed by the name. ASSUMED, not measured -- see the module
|
|
182
|
+
* header's status note. An unrecognised line yields null, never a guess drawn
|
|
183
|
+
* from the surrounding text.
|
|
184
|
+
*/
|
|
185
|
+
const ORACLE_NAME_MARKERS = Object.freeze([/^packer\s*:\s*(.+)$/i, /^detected\s+packer\s*:\s*(.+)$/i, /^detected\s*:\s*(.+)$/i]);
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// Entropy -- packedness only
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Shannon entropy over a byte buffer, 0.0 to 8.0 -- the same quantity the
|
|
193
|
+
* curated binary-info tool reports, computed here so the command-line entry
|
|
194
|
+
* can answer from a bare file with no live session.
|
|
195
|
+
*
|
|
196
|
+
* Returns null for an empty buffer: zero bytes is an absent measurement, not
|
|
197
|
+
* a measurement of zero (a zero would read as "definitely not compressed",
|
|
198
|
+
* which is a claim nobody made).
|
|
199
|
+
*/
|
|
200
|
+
export function shannonEntropy(bytes) {
|
|
201
|
+
if (!bytes || bytes.length === 0) return null;
|
|
202
|
+
const counts = new Array(256).fill(0);
|
|
203
|
+
for (const b of bytes) counts[b & 0xff]++;
|
|
204
|
+
let total = 0;
|
|
205
|
+
for (const count of counts) {
|
|
206
|
+
if (count === 0) continue;
|
|
207
|
+
const p = count / bytes.length;
|
|
208
|
+
total -= p * Math.log2(p);
|
|
209
|
+
}
|
|
210
|
+
return total;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// The oracle: probe, run, parse. All three read-only.
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Locates and probes the external packer identifier, WITHOUT touching any
|
|
219
|
+
* input file.
|
|
220
|
+
*
|
|
221
|
+
* Resolution order: the two environment variables, then the bare command name
|
|
222
|
+
* on the search path. A CONFIGURED path that does not exist on disk is
|
|
223
|
+
* reported as absent and is deliberately not echoed back in the reason
|
|
224
|
+
* (T-19-18: an attacker-influenceable value is never placed into a command,
|
|
225
|
+
* and not into a message a later step might paste into one either).
|
|
226
|
+
*
|
|
227
|
+
* Never throws. A launch error, a non-zero status or a timeout are all
|
|
228
|
+
* "absent", never a failure -- absence of the oracle is an expected state.
|
|
229
|
+
*/
|
|
230
|
+
export function probeUnp64(env = process.env) {
|
|
231
|
+
const source = env ?? {};
|
|
232
|
+
let configuredVar = null;
|
|
233
|
+
let configured = null;
|
|
234
|
+
for (const name of ORACLE_ENV_VARS) {
|
|
235
|
+
const value = source[name];
|
|
236
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
237
|
+
configuredVar = name;
|
|
238
|
+
configured = value.trim();
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (configured !== null && !existsSync(configured)) {
|
|
244
|
+
return {
|
|
245
|
+
available: false,
|
|
246
|
+
command: null,
|
|
247
|
+
version: null,
|
|
248
|
+
reason:
|
|
249
|
+
`the packer identifier configured through ${configuredVar} does not exist on disk -- ` +
|
|
250
|
+
"treated as oracle-absent, and the configured value was not placed into any command",
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const command = configured ?? DEFAULT_ORACLE_COMMAND;
|
|
255
|
+
const probe = spawnSync(command, ["--version"], {
|
|
256
|
+
encoding: "utf8",
|
|
257
|
+
timeout: ORACLE_TIMEOUT_MS,
|
|
258
|
+
shell: false,
|
|
259
|
+
windowsHide: true,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
if (probe.error) {
|
|
263
|
+
return {
|
|
264
|
+
available: false,
|
|
265
|
+
command: null,
|
|
266
|
+
version: null,
|
|
267
|
+
reason:
|
|
268
|
+
configuredVar === null
|
|
269
|
+
? `no "${DEFAULT_ORACLE_COMMAND}" packer identifier was found on the search path`
|
|
270
|
+
: `the packer identifier configured through ${configuredVar} could not be launched`,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const banner = `${probe.stdout ?? ""}${probe.stderr ?? ""}`.trim();
|
|
275
|
+
if (banner === "") {
|
|
276
|
+
return {
|
|
277
|
+
available: false,
|
|
278
|
+
command: null,
|
|
279
|
+
version: null,
|
|
280
|
+
reason: "the packer identifier produced no version banner, so it was not accepted as an oracle",
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return { available: true, command, version: banner.slice(0, 200), reason: null };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Runs the located identifier against `filePath`, read-only.
|
|
289
|
+
*
|
|
290
|
+
* The input file is never modified: any unpacked output the identifier writes
|
|
291
|
+
* goes to a scratch path under the system temporary directory, which is
|
|
292
|
+
* removed before this function returns (T-19-24). The child is launched with
|
|
293
|
+
* an argument ARRAY and an explicitly disabled command interpreter, so neither
|
|
294
|
+
* the configured command nor the caller's filename is ever parsed as a
|
|
295
|
+
* command (T-19-18).
|
|
296
|
+
*
|
|
297
|
+
* Never throws: every failure is reported as `{ ok: false, reason }`.
|
|
298
|
+
*/
|
|
299
|
+
export function runUnp64(probe, filePath) {
|
|
300
|
+
if (!probe || probe.available !== true || typeof probe.command !== "string") {
|
|
301
|
+
return { ok: false, stdout: "", reason: "the oracle was not available" };
|
|
302
|
+
}
|
|
303
|
+
if (typeof filePath !== "string" || filePath === "") {
|
|
304
|
+
return { ok: false, stdout: "", reason: "no input file was given to the oracle" };
|
|
305
|
+
}
|
|
306
|
+
if (!existsSync(filePath)) {
|
|
307
|
+
return { ok: false, stdout: "", reason: "the input file does not exist" };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
let scratch = null;
|
|
311
|
+
try {
|
|
312
|
+
scratch = mkdtempSync(join(tmpdir(), "packer-finding-"));
|
|
313
|
+
const scratchOut = join(scratch, "unpacked.out");
|
|
314
|
+
const run = spawnSync(probe.command, [filePath, scratchOut], {
|
|
315
|
+
encoding: "utf8",
|
|
316
|
+
timeout: ORACLE_TIMEOUT_MS,
|
|
317
|
+
shell: false,
|
|
318
|
+
windowsHide: true,
|
|
319
|
+
maxBuffer: MAX_ORACLE_STDOUT_BYTES,
|
|
320
|
+
});
|
|
321
|
+
if (run.error) {
|
|
322
|
+
return { ok: false, stdout: "", reason: "the oracle could not be run against the input file" };
|
|
323
|
+
}
|
|
324
|
+
return { ok: true, stdout: `${run.stdout ?? ""}`, reason: null };
|
|
325
|
+
} catch {
|
|
326
|
+
// A scratch directory that could not be created is an absent oracle, not
|
|
327
|
+
// an error a recon pass should stop for.
|
|
328
|
+
return { ok: false, stdout: "", reason: "a scratch directory for the oracle's output could not be created" };
|
|
329
|
+
} finally {
|
|
330
|
+
if (scratch !== null) {
|
|
331
|
+
try {
|
|
332
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
333
|
+
} catch {
|
|
334
|
+
// Best effort. A leftover empty scratch directory is not worth
|
|
335
|
+
// failing a read-only recon finding over.
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Parses a packer name out of the oracle's standard output.
|
|
343
|
+
*
|
|
344
|
+
* Defensive by construction (T-19-19): an explicit byte cap before anything
|
|
345
|
+
* is scanned, no evaluation of any kind, a narrow accepted character set, and
|
|
346
|
+
* a length cap on the result. Empty, truncated, over-long and unrecognised
|
|
347
|
+
* input all return null WITHOUT throwing -- a parser that throws inside a
|
|
348
|
+
* recon pass would turn an unhelpful oracle into a stopped session.
|
|
349
|
+
*
|
|
350
|
+
* The returned value's only destination is the finding's `packer` field. It
|
|
351
|
+
* never reaches a command, a path, or any executable position.
|
|
352
|
+
*/
|
|
353
|
+
export function parseUnp64Stdout(text) {
|
|
354
|
+
if (typeof text !== "string") return null;
|
|
355
|
+
if (text.length === 0) return null;
|
|
356
|
+
if (text.length > MAX_ORACLE_STDOUT_BYTES) return null;
|
|
357
|
+
|
|
358
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
359
|
+
const line = rawLine.trim();
|
|
360
|
+
if (line === "") continue;
|
|
361
|
+
for (const marker of ORACLE_NAME_MARKERS) {
|
|
362
|
+
const matched = line.match(marker);
|
|
363
|
+
if (!matched) continue;
|
|
364
|
+
const candidate = (matched[1] ?? "").trim();
|
|
365
|
+
if (candidate === "") return null;
|
|
366
|
+
if (candidate.length > MAX_PACKER_NAME_LENGTH) return null;
|
|
367
|
+
if (!PACKER_NAME_RE.test(candidate)) return null;
|
|
368
|
+
return candidate;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ---------------------------------------------------------------------------
|
|
375
|
+
// Finding constructors. Each route has exactly one, and only the oracle's
|
|
376
|
+
// constructor may set `packer`.
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* THE ONLY PLACE IN THIS MODULE THAT ASSIGNS A PACKER NAME (rule 1), and the
|
|
381
|
+
* only place the high confidence level is written (rule 2). It refuses to
|
|
382
|
+
* build a finding from anything but a non-empty string the oracle's own
|
|
383
|
+
* output supplied.
|
|
384
|
+
*/
|
|
385
|
+
function identifiedByOracle(name, evidence, checkedAt) {
|
|
386
|
+
if (typeof name !== "string" || name.trim() === "") {
|
|
387
|
+
throw new Error("identifiedByOracle: refusing to report a packer name the oracle did not state");
|
|
388
|
+
}
|
|
389
|
+
return Object.freeze({
|
|
390
|
+
packer: name,
|
|
391
|
+
verdict: VERDICT_IDENTIFIED,
|
|
392
|
+
confidence: CONFIDENCE_HIGH,
|
|
393
|
+
route: ROUTE_ORACLE,
|
|
394
|
+
evidence: Object.freeze(evidence.slice()),
|
|
395
|
+
checkedAt,
|
|
396
|
+
unavailableReason: null,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** The entropy route. Answers PACKEDNESS and nothing else -- `packer` is
|
|
401
|
+
* written as the literal null here, and the entropy value has no path to it
|
|
402
|
+
* (rule 1). Never the high confidence level (rule 2). */
|
|
403
|
+
function fromEntropy(entropy, threshold, evidence, checkedAt) {
|
|
404
|
+
const packed = entropy >= threshold;
|
|
405
|
+
return Object.freeze({
|
|
406
|
+
packer: null,
|
|
407
|
+
verdict: packed ? VERDICT_PACKED_UNIDENTIFIED : VERDICT_UNPACKED,
|
|
408
|
+
confidence: CONFIDENCE_MEDIUM,
|
|
409
|
+
route: ROUTE_ENTROPY,
|
|
410
|
+
evidence: Object.freeze(evidence.slice()),
|
|
411
|
+
checkedAt,
|
|
412
|
+
unavailableReason: packed
|
|
413
|
+
? "the entropy gate answers packedness only -- no oracle named the packer, and this project never " +
|
|
414
|
+
"infers a name from entropy, from a decompression address, or from a byte pattern"
|
|
415
|
+
: "no name is claimed: the entropy gate places these bytes below its packedness threshold, which is " +
|
|
416
|
+
"a statement about compression and not about identity",
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** The no-route case. Rule 3 is enforced here: an `unknown` verdict with an
|
|
421
|
+
* empty reason cannot be constructed at all. */
|
|
422
|
+
function unknownFinding(reason, evidence, checkedAt) {
|
|
423
|
+
if (typeof reason !== "string" || reason.trim() === "") {
|
|
424
|
+
throw new Error("unknownFinding: refusing to return an unknown verdict with no reason (rule 3)");
|
|
425
|
+
}
|
|
426
|
+
return Object.freeze({
|
|
427
|
+
packer: null,
|
|
428
|
+
verdict: VERDICT_UNKNOWN,
|
|
429
|
+
confidence: CONFIDENCE_LOW,
|
|
430
|
+
route: ROUTE_NONE,
|
|
431
|
+
evidence: Object.freeze(evidence.slice()),
|
|
432
|
+
checkedAt,
|
|
433
|
+
unavailableReason: reason,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
// The finding
|
|
439
|
+
// ---------------------------------------------------------------------------
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Produces the packer recon finding for one binary, walking the ordered oracle
|
|
443
|
+
* chain: the external identifier first (first hit wins for the name), then the
|
|
444
|
+
* packedness-only entropy gate, then an explicit unknown.
|
|
445
|
+
*
|
|
446
|
+
* Options:
|
|
447
|
+
* filePath -- the binary to ask the oracle about. Required for the oracle
|
|
448
|
+
* route; the entropy route does not need it when `entropy` is
|
|
449
|
+
* supplied directly.
|
|
450
|
+
* entropy -- a measured entropy value (for example the one the curated
|
|
451
|
+
* binary-info tool reports). When absent and `bytes` is given,
|
|
452
|
+
* it is computed locally; the evidence entry records WHICH.
|
|
453
|
+
* bytes -- the binary's bytes, for the local entropy computation.
|
|
454
|
+
* threshold -- overrides the packedness threshold. The default is the one
|
|
455
|
+
* the curated tool's own description states.
|
|
456
|
+
* probe -- injectable oracle probe, so a test can force the oracle
|
|
457
|
+
* absent without uninstalling anything.
|
|
458
|
+
* run -- injectable oracle runner, same reason.
|
|
459
|
+
* now -- injectable clock, so a test can pin `checkedAt`.
|
|
460
|
+
*/
|
|
461
|
+
export function packerFinding(options = {}) {
|
|
462
|
+
const {
|
|
463
|
+
filePath = null,
|
|
464
|
+
entropy = null,
|
|
465
|
+
bytes = null,
|
|
466
|
+
threshold = PACKED_ENTROPY_THRESHOLD,
|
|
467
|
+
env = process.env,
|
|
468
|
+
probe = probeUnp64,
|
|
469
|
+
run = runUnp64,
|
|
470
|
+
now = () => new Date().toISOString(),
|
|
471
|
+
} = options;
|
|
472
|
+
|
|
473
|
+
const checkedAt = now();
|
|
474
|
+
const evidence = [];
|
|
475
|
+
|
|
476
|
+
// --- Route 1: the external oracle. The ONLY route that can name a packer.
|
|
477
|
+
const probed = probe(env);
|
|
478
|
+
evidence.push(
|
|
479
|
+
Object.freeze({
|
|
480
|
+
source: ROUTE_ORACLE,
|
|
481
|
+
available: probed.available === true,
|
|
482
|
+
version: probed.available === true ? probed.version : null,
|
|
483
|
+
reason: probed.available === true ? null : (probed.reason ?? "the oracle was not available"),
|
|
484
|
+
}),
|
|
485
|
+
);
|
|
486
|
+
|
|
487
|
+
if (probed.available === true && typeof filePath === "string" && filePath !== "") {
|
|
488
|
+
const result = run(probed, filePath);
|
|
489
|
+
const raw = typeof result.stdout === "string" ? result.stdout.slice(0, MAX_PACKER_NAME_LENGTH * 8) : "";
|
|
490
|
+
evidence.push(Object.freeze({ source: ROUTE_ORACLE, ok: result.ok === true, raw, reason: result.reason ?? null }));
|
|
491
|
+
if (result.ok === true) {
|
|
492
|
+
const name = parseUnp64Stdout(result.stdout);
|
|
493
|
+
if (name !== null) {
|
|
494
|
+
return identifiedByOracle(name, evidence, checkedAt);
|
|
495
|
+
}
|
|
496
|
+
// The oracle ran and named nothing. That is a real answer about the
|
|
497
|
+
// oracle, not about the packer -- fall through to the entropy gate
|
|
498
|
+
// rather than inventing a name from what it did print.
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// --- Route 2: the entropy gate. Packedness only, never a name.
|
|
503
|
+
let measured = null;
|
|
504
|
+
let entropySource = null;
|
|
505
|
+
if (typeof entropy === "number" && Number.isFinite(entropy)) {
|
|
506
|
+
measured = entropy;
|
|
507
|
+
// THE RECORDED PROVENANCE VALUE, DECIDED 2026-08-29 -- not string-replaced.
|
|
508
|
+
// Until this date this field carried the retired static analyser's
|
|
509
|
+
// binary-info verb, in that verb's own tool-name shape. Carrying that
|
|
510
|
+
// shape forward under ANY spelling is wrong twice over. It is a FACT
|
|
511
|
+
// ABOUT A PAST RUN: renaming it to a verb on the current surface would
|
|
512
|
+
// claim a run that never happened. And this branch cannot know who
|
|
513
|
+
// produced the number in the first place -- `--entropy` is
|
|
514
|
+
// caller-supplied, and the caller may equally have measured it, read it
|
|
515
|
+
// from a derived binary-info read, or copied it out of a report. So the
|
|
516
|
+
// value now names the CHANNEL, which this code can actually observe,
|
|
517
|
+
// rather than guessing a producer; the historical producer is recorded
|
|
518
|
+
// above in prose, with no token an extractor could mistake for a live
|
|
519
|
+
// route.
|
|
520
|
+
entropySource = "caller-supplied";
|
|
521
|
+
} else if (bytes && bytes.length > 0) {
|
|
522
|
+
measured = shannonEntropy(bytes);
|
|
523
|
+
entropySource = "local-shannon-entropy";
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
if (measured !== null) {
|
|
527
|
+
evidence.push(Object.freeze({ source: entropySource, entropy: measured, threshold }));
|
|
528
|
+
return fromEntropy(measured, threshold, evidence, checkedAt);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// --- Route 3: the explicit unknown, always with a reason.
|
|
532
|
+
return unknownFinding(
|
|
533
|
+
"no packer-identity route was available: this project's own tool and command-line surfaces expose no " +
|
|
534
|
+
"packer name, the external oracle was absent, and no entropy measurement was supplied, so packedness " +
|
|
535
|
+
"could not be established either",
|
|
536
|
+
evidence,
|
|
537
|
+
checkedAt,
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// ---------------------------------------------------------------------------
|
|
542
|
+
// The live gate. Absence of the oracle is an EXPECTED SKIP by default and a
|
|
543
|
+
// hard FAIL under the opt-in variable -- never a pass either way.
|
|
544
|
+
// ---------------------------------------------------------------------------
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Returns a non-empty skip reason naming the absent oracle, or `false` when a
|
|
548
|
+
* real one is available -- meant to be handed straight to a test runner's
|
|
549
|
+
* `{ skip }` option so the skip is VISIBLE in the report rather than a test
|
|
550
|
+
* that quietly returns early and reads as a pass.
|
|
551
|
+
*/
|
|
552
|
+
export function skipReasonForUnp64(probed) {
|
|
553
|
+
const result = probed ?? probeUnp64();
|
|
554
|
+
if (result.available === true) return false;
|
|
555
|
+
return (
|
|
556
|
+
`the oracle-route tests are skipped -- no external packer identifier was found (${result.reason ?? "reason not recorded"}). ` +
|
|
557
|
+
`Point ${ORACLE_ENV_VARS.join(" or ")} at one, or install "${DEFAULT_ORACLE_COMMAND}". ` +
|
|
558
|
+
`An absent oracle is an EXPECTED SKIP here, never a pass: set ${REQUIRE_ORACLE_ENV_VAR} to turn it into a hard failure.`
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ---------------------------------------------------------------------------
|
|
563
|
+
// Command-line entry: one file in, one JSON object out.
|
|
564
|
+
// ---------------------------------------------------------------------------
|
|
565
|
+
|
|
566
|
+
const USAGE = `usage: node src/skills/c64-program-recon/scripts/packer-finding.mjs <file> [--entropy N]
|
|
567
|
+
|
|
568
|
+
Prints ONE JSON object: the packer recon finding for <file>.
|
|
569
|
+
|
|
570
|
+
packer the packer name, or null. Non-null ONLY when an external
|
|
571
|
+
oracle stated it verbatim. This project never guesses one.
|
|
572
|
+
verdict one of: ${PACKER_VERDICTS.join(", ")}
|
|
573
|
+
confidence HIGH (oracle route only), MEDIUM (entropy route), LOW
|
|
574
|
+
route ${ROUTE_ORACLE}, ${ROUTE_ENTROPY}, or ${ROUTE_NONE}
|
|
575
|
+
evidence what each step actually saw
|
|
576
|
+
checkedAt when this was answered
|
|
577
|
+
unavailableReason why no name is being reported, whenever there is none
|
|
578
|
+
|
|
579
|
+
--entropy overrides the locally computed value with one you already have (the
|
|
580
|
+
curated binary-info tool reports it). The entropy gate answers PACKEDNESS and
|
|
581
|
+
never identity.`;
|
|
582
|
+
|
|
583
|
+
function readFlag(argv, name) {
|
|
584
|
+
const index = argv.indexOf(`--${name}`);
|
|
585
|
+
if (index === -1) return undefined;
|
|
586
|
+
const value = argv[index + 1];
|
|
587
|
+
if (value === undefined || value.startsWith("--")) return undefined;
|
|
588
|
+
return value;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function main(argv) {
|
|
592
|
+
const positional = [];
|
|
593
|
+
for (let i = 0; i < argv.length; i++) {
|
|
594
|
+
const token = argv[i];
|
|
595
|
+
if (token === "--entropy") {
|
|
596
|
+
i++;
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
if (token.startsWith("--")) continue;
|
|
600
|
+
positional.push(token);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const filePath = positional[0];
|
|
604
|
+
if (filePath === undefined || argv.includes("--help") || argv.includes("-h")) {
|
|
605
|
+
console.log(USAGE);
|
|
606
|
+
process.exit(filePath === undefined && !argv.includes("--help") && !argv.includes("-h") ? 2 : 0);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
let bytes = null;
|
|
610
|
+
try {
|
|
611
|
+
bytes = readFileSync(filePath);
|
|
612
|
+
} catch (err) {
|
|
613
|
+
console.error(`packer-finding: could not read ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
614
|
+
process.exit(1);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const entropyRaw = readFlag(argv, "entropy");
|
|
618
|
+
const entropy = entropyRaw === undefined ? null : Number.parseFloat(entropyRaw);
|
|
619
|
+
if (entropyRaw !== undefined && !Number.isFinite(entropy)) {
|
|
620
|
+
console.error(`packer-finding: --entropy must be a number, got "${entropyRaw}"`);
|
|
621
|
+
process.exit(1);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const finding = packerFinding({ filePath, bytes, ...(entropy === null ? {} : { entropy }) });
|
|
625
|
+
console.log(JSON.stringify(finding, null, 2));
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Run only when invoked directly, never when imported by the colocated test.
|
|
629
|
+
if (process.argv[1] && process.argv[1].endsWith("packer-finding.mjs")) {
|
|
630
|
+
main(process.argv.slice(2));
|
|
631
|
+
}
|