122 lines
4.4 KiB
JavaScript
122 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;
|
|
}
|