rea/scripts/lib/real-hopper-source-binding.mjs
morluto 82626c4499 fix(hopper): bind file mappings to verified source bytes
Read source digests, FAT tables and embedded headers from one opened source stream before translating native offsets. Preserve native mapping observations through changed, missing, nonregular and access-denied source failures; keep filesystem work outside the Hopper API thread.

Exercise source drift, relocated slices, missing files, permissions and recovery through real Hopper CLI/MCP verification for thin, FAT32 and FAT64 inputs.
2026-10-09 06:13:41 +08:00

121 lines
4.4 KiB
JavaScript

import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { chmod, mkdir, rename, rm, stat, writeFile } from "node:fs/promises";
import { mcpTextValue } from "./mcp-verifier-results.mjs";
/** Exercise source replacement, removal, and restoration against a loaded native document. */
export async function verifyHopperSourceBinding({
client,
call,
path,
file,
address,
mapping,
relocate,
}) {
const originalHash = createHash("sha256").update(file).digest("hex");
const changed = relocate === undefined ? Buffer.from(file) : relocate(file);
if (relocate === undefined) changed[changed.length - 1] ^= 1;
const changedHash = createHash("sha256").update(changed).digest("hex");
const at = (delta) => `0x${(BigInt(address) + BigInt(delta)).toString(16)}`;
const observeFailure = async (delta) => {
const reply = await client.callTool(
{ name: "address_to_file_offset", arguments: { address: at(delta) } },
{ timeout: 180_000 },
);
assert.equal(
reply.isError,
true,
"mapping unexpectedly accepted an unbound source",
);
const error = JSON.parse(mcpTextValue(reply)).error;
const partial = error.details.partial_observation;
assert.equal(partial.operation, "address_to_file_offset");
assert.equal(partial.address, at(delta));
assert.equal(
partial.provider_file_offset,
mapping.provider_file_offset + delta,
);
assert.equal(partial.original_file_offset.available, false);
assert.equal(typeof partial.original_file_offset.reason, "string");
assert.match(partial.provider_image_header_hex, /^[a-f0-9]{24}$/u);
return error;
};
try {
await writeFile(path, changed);
const error = await observeFailure(2);
assert.equal(error.code, "artifact_changed");
assert.ok(error.details.reason.includes(originalHash));
assert.ok(error.details.reason.includes(changedHash));
assert.equal((await call("binary_session")).sha256, originalHash);
assert.equal(
(await call("read_bytes", { address: at(2), length: 1 })).bytes_hex,
file
.subarray(mapping.file_offset + 2, mapping.file_offset + 3)
.toString("hex"),
);
} finally {
await writeFile(path, file);
}
const renamed = `${path}.temporarily-moved`;
await rename(path, renamed);
try {
const error = await observeFailure(3);
assert.equal(error.code, "target_unavailable");
assert.match(error.details.reason, /ENOENT/u);
} finally {
await rename(renamed, path);
}
await rename(path, renamed);
try {
await mkdir(path);
const error = await observeFailure(4);
assert.equal(error.code, "artifact_changed");
assert.match(error.details.reason, /no longer a regular file/u);
} finally {
await rm(path, { recursive: true, force: true });
await rename(renamed, path);
}
const permissionDenied = process.getuid() !== 0;
if (permissionDenied) {
const mode = (await stat(path)).mode & 0o777;
try {
await chmod(path, 0);
const error = await observeFailure(5);
assert.equal(error.code, "access_denied");
assert.ok(["EACCES", "EPERM"].includes(error.details.system_code));
} finally {
await chmod(path, mode);
}
}
for (const delta of [2, 3, 4, ...(permissionDenied ? [5] : [])]) {
const recovered = await call("address_to_file_offset", {
address: at(delta),
});
assert.equal(recovered.file_offset, mapping.file_offset + delta);
}
return {
replacement: relocate === undefined ? "same_size_bytes" : "relocated_slice",
missingSource: "verified",
nonregularSource: "verified",
permissionDenied: permissionDenied ? "verified" : "not_run_root",
nativePartialEvidence: "verified",
recovery: "verified",
};
}
/** Relocate the sole FAT slice while retaining its exact loaded bytes and CPU identity. */
export function relocateSingleFatSlice(file) {
const wide = file.readUInt32BE(0) === 0xcafebabf;
assert.equal(file.readUInt32BE(4), 1);
const offset = wide
? Number(file.readBigUInt64BE(16))
: file.readUInt32BE(16);
const size = wide ? Number(file.readBigUInt64BE(24)) : file.readUInt32BE(20);
const relocated = Buffer.alloc(offset * 2 + size);
file.subarray(0, offset).copy(relocated);
file.subarray(offset, offset + size).copy(relocated, offset * 2);
if (wide) relocated.writeBigUInt64BE(BigInt(offset * 2), 16);
else relocated.writeUInt32BE(offset * 2, 16);
return relocated;
}