Compare commits

...
Author SHA1 Message Date
morlutoandGitHub f1985d15fb Merge branch 'main' into codex/review-2049-27f1b367 2026-10-14 03:08:33 +08:00
morluto f531dd396c Merge commit 'refs/codex/pr-refresh/main-20261014-phase2' into codex/pr-refresh-2828-20261014 2026-10-14 02:23:08 +08:00
morluto ff48333a2b chore: sync latest main into PR #2828 2026-10-14 02:07:02 +08:00
morluto 7fb429ac6d style: format identity comparison and IDA runner 2026-10-14 01:01:46 +08:00
Lucienandmorluto f099269345 fix(comparison): recheck historical calls-only changes (#2223)
* fix(comparison): preserve incomplete function call evidence

Keep missing, unknown, partial and unresolved call relationships unknown in function comparisons and reconstruction claims. Aggregate IDA caller and callee coverage without overwriting uncertainty.

Validate with 144 focused tests, typecheck/lint, docs checks and test discovery. Real providers were not verified; the full headless IDA session suite requires unavailable Windows native controls.

* fix(comparison): recheck historical calls-only changes

---------

Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-14 01:01:43 +08:00
REA Maintenance 44253633d5 fix(ci): isolate Linux Hopper process fixture inventory 2026-10-14 01:01:43 +08:00
morluto cb6e296834 fix(android): make DEX descriptor pattern portable 2026-10-14 01:01:43 +08:00
morluto 1e96613e9e fix(ghidra): supervise Swift probes and cover retained text capacity 2026-10-13 22:57:42 +08:00
morluto b475d35e97 chore: sync captured main into Swift decoder follow-up 2026-10-13 21:59:40 +08:00
morluto 27f1b367b9 test(ghidra): cover Swift decoder rejection and capacity branches 2026-10-13 21:39:11 +08:00
morluto f8f18e0bc7 fix(ghidra): keep unresolved Swift tail-call sites unknown 2026-10-13 20:28:58 +08:00
morluto d3ba7243d7 fix(ghidra): preserve Swift literal provenance and partial coverage 2026-10-13 20:27:37 +08:00
Omisakin Joshua 59be5357df fix(ghidra): stop every flow at the literal budget and route omissions
Ending one flow from the evaluator leaves other queued flows running, so
the budget now records a shared stop that every flow checks before it
examines another instruction. A record that exceeds the budget is routed
by that capacity result rather than by whether text was decoded, so a
small literal over budget becomes an omission instead of a literal.

When coverage is partial, the per site comparison keeps each literal's
representation as well as its value, matching the full strings_names
projection.
2026-10-12 12:07:40 +01:00
Omisakin Joshua 560257b00e fix(ghidra): bound literal records and trace selects across branches
Every Swift literal record now pays a fixed cost against the dossier
budget in addition to its text, so many empty or small literals are
bounded as well. The first record that does not fit is kept as an
omission and decoding stops there, which keeps the omission record
itself bounded; omissions now describe small or large literals.

The conditional select check searches every predecessor path for the
reaching definitions, following register copies, so selects made
before a branch into the use block are recognized.

A dossier records Swift literals and their omissions together. Coverage
reads omissions first, and strings_names compares every use site that
neither side omitted: a decoded literal is known where it was recorded,
and an absence is known only in a dossier whose decoding examined every
use site.
2026-10-12 11:40:02 +01:00
Omisakin Joshua d5b2edc217 fix(ghidra): charge escaped JSON and follow copied selects
The Swift literal budget now charges the worst case per UTF-8 byte: UTF-16
text, six escaped JSON characters held in Gson's growing buffer and in the
returned String, and the UTF-8 bytes written to the socket. Literals made
of control characters can no longer exceed the reserved heap.

A jump counts as a tail call only when every resolved target lies outside
the function. The conditional select check follows register copies back
to their definition, so selects copied into argument registers are
recognized like selects written there directly.

strings_names reports a change when both dossiers decoded different
literals at the same use site, even when either side has omissions; it
returns unknown only when no such use site disagrees.
2026-10-12 11:02:23 +01:00
Omisakin Joshua facd58973a fix(ghidra): budget Swift literal decoding and tighten use sites
Large literals now draw on one heap budget per dossier, measured once
before decoding, instead of a separate check per literal. A literal that
does not fit is listed in a new swift_string_literal_omissions field
with its address, byte length and reason, so consumers read omissions
from structured data rather than from limitation text.

strings_names comparison checks referenced strings and names first and
reports a change there even when Swift literal coverage differs; it
returns unknown only when those facts match and literal coverage is
absent on one side or partial.

A computed jump counts as a tail call only when it has no resolved
target inside the function, so jump tables no longer qualify, and a
return requires a RETURN p-code operation, so traps no longer qualify.

Decoded literals keep control characters, which Swift strings may
contain and JSON escapes. The Swift verifier builds the literal fixture
for arm64 on any Mac host and now also covers the empty string and a
literal containing a tab.
2026-10-12 10:42:03 +01:00
Cursor Agent 702dc9f932 fix(ghidra): bound Swift literal reads and compare decoded strings
Large immortal literals are measured against the remaining heap before
allocation, and a failed fit stays visible as a limitation. The canonical
empty string is kept. Small and large literals now require matching
address nullability, and function comparison treats decoded literals as
string evidence without equating a missing record to an empty one.
2026-10-12 05:04:18 +00:00
Omisakin Joshua 2f89a1b5c0 feat(ghidra): decode Swift String literals in function dossiers
Swift passes a String as two words. Small literals keep their UTF-8 bytes
inside those words, and immortal large literals keep a count plus a pointer
to their bytes biased by 32. Ghidra creates no data reference for either, so
analyze_function reported no strings for Swift code.

On AArch64 programs that contain Swift metadata, the Ghidra bridge now runs
constant propagation over the function and decodes register pairs where
Swift consumes a String: consecutive argument registers at calls and tail
calls, x0 and x1 at returns, and both registers of one STP. Large literals
must resolve to terminated bytes inside one initialized block that is not
executable. A pair whose registers both come from conditional selects is
skipped, because Ghidra forks each select without correlating the
condition and could otherwise pair words from different cases.

Decoded values are a derivation from the ABI layout rather than observed
references, so they appear in a new optional swift_string_literals field
with their source instruction, registers and raw words, and in a matching
inspect_analysis_view facet. referenced_strings is unchanged.

Refs #2030
2026-10-12 05:20:08 +01:00
4 changed files with 364 additions and 3 deletions
+10 -1
View File
@@ -3632,13 +3632,17 @@ public final class ReaGhidraBridge extends HeadlessScript {
* The canonical empty string is a small literal with a zero count.
*/
private SwiftStringLiteralDecode swiftStringLiterals(Function function) throws Exception {
return swiftStringLiterals(function, new SwiftLiteralBudget());
}
// The budget is owned by one decode run; an explicit budget also permits deterministic producer regressions.
private SwiftStringLiteralDecode swiftStringLiterals(Function function, SwiftLiteralBudget budget) throws Exception {
Register[] registers = new Register[31];
for (int index = 0; index < registers.length; index++) {
registers[index] = currentProgram.getRegister("x" + index);
}
TreeMap<String, JsonObject> observed = new TreeMap<>();
TreeMap<String, JsonObject> omitted = new TreeMap<>();
SwiftLiteralBudget budget = new SwiftLiteralBudget();
Map<String, Boolean> selectedValues = new HashMap<>();
SymbolicPropogator propagator = new SymbolicPropogator(currentProgram, false);
propagator.flowConstants(function.getEntryPoint(), function.getBody(), new ContextEvaluatorAdapter() {
@@ -3883,6 +3887,11 @@ public final class ReaGhidraBridge extends HeadlessScript {
remaining = Math.max(0, (runtime.maxMemory() - runtime.totalMemory() + runtime.freeMemory()) / 2);
}
SwiftLiteralBudget(long remaining) {
if (remaining < 0) throw new IllegalArgumentException("Swift literal budget must be nonnegative");
this.remaining = remaining;
}
boolean takeState() {
if (remaining < BYTES_PER_RECORD) return false;
remaining -= BYTES_PER_RECORD;
+144 -2
View File
@@ -1,7 +1,14 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { copyFile, mkdir, mkdtemp, rm } from "node:fs/promises";
import {
copyFile,
mkdir,
mkdtemp,
readFile,
readdir,
rm,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
@@ -9,6 +16,11 @@ import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
import { inspectGhidraInstallation } from "../dist/ghidra/GhidraInstallation.js";
import { cleanupOwnedProcessGroup } from "../dist/process/ProcessOwnership.js";
import {
ProviderProcessSupervisor,
spawnOwnedProviderProcess,
} from "../dist/process/ProviderProcess.js";
import { toolContract } from "../dist/contracts/toolContracts.js";
import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
@@ -50,8 +62,13 @@ const literalSource = fileURLToPath(
);
const binary = join(root, "swift-inventory");
const literalBinary = join(root, "swift-literals");
const literalAssembly = fileURLToPath(
new URL("../tests/conformance/swift/decoder-safeguards.S", import.meta.url),
);
const literalObject = join(root, "decoder-safeguards.o");
const reports = [];
let literalReport;
let decoderCleaned = true;
try {
await execute("swiftc", ["-O", "-g", source, "-o", binary], {
timeout: 60_000,
@@ -59,6 +76,19 @@ try {
await mkdir(join(root, "copied"));
const copied = join(root, "copied", "swift-inventory");
await copyFile(binary, copied);
await execute(
"xcrun",
[
"clang",
"-target",
"arm64-apple-macosx13.0",
"-c",
literalAssembly,
"-o",
literalObject,
],
{ timeout: 60_000 },
);
// Decoding covers AArch64 register pairs, so build that slice on any Mac host.
await execute(
"swiftc",
@@ -67,11 +97,13 @@ try {
"-target",
"arm64-apple-macosx13.0",
literalSource,
literalObject,
"-o",
literalBinary,
],
{ timeout: 60_000 },
);
await verifyDecoderSafeguards();
await client.connect(transport);
for (const [variant, path] of [
["debug", binary],
@@ -154,7 +186,7 @@ try {
} finally {
await client.close();
await transport.close();
await rm(root, { recursive: true, force: true });
if (decoderCleaned) await rm(root, { recursive: true, force: true });
}
console.log(
JSON.stringify(
@@ -227,3 +259,113 @@ async function verifyStringLiterals() {
await call("close_binary", {});
}
}
// Exercise the actual Java producer inside a disposable Ghidra database. The
// explicit private budget boundary makes exhaustion deterministic, without a
// production CLI/MCP option or dependence on the verification host's free heap.
async function verifyDecoderSafeguards() {
const scripts = join(root, "decoder-scripts");
await mkdir(scripts);
const bridgeSources = fileURLToPath(
new URL("../bridge/ghidra/", import.meta.url),
);
for (const name of await readdir(bridgeSources)) {
if (name.endsWith(".java"))
await copyFile(join(bridgeSources, name), join(scripts, name));
}
await copyFile(
fileURLToPath(
new URL(
"../tests/conformance/swift/ReaSwiftDecoderRegressions.java",
import.meta.url,
),
),
join(scripts, "ReaSwiftDecoderRegressions.java"),
);
const report = join(root, "decoder-safeguards.txt");
await runDecoderProbe(scripts, report);
assert.equal(
await readFile(report, "utf8"),
"REA_SWIFT_DECODER_SAFEGUARDS_OK\n",
);
}
async function runDecoderProbe(scripts, report) {
const controller = new AbortController();
let interrupt;
const interrupted = new Promise((resolve) => {
interrupt = () => {
controller.abort(new Error("Swift decoder probe interrupted"));
resolve(false);
};
});
process.on("SIGINT", interrupt);
process.on("SIGTERM", interrupt);
let supervisor;
try {
const launch = await spawnOwnedProviderProcess({
command: installation.analyzeHeadlessPath,
arguments: [
root,
"decoder-regressions",
"-import",
literalBinary,
"-scriptPath",
scripts,
"-postScript",
"ReaSwiftDecoderRegressions.java",
report,
"-deleteProject",
"-max-cpu",
"2",
],
runId: run.run_id,
expectedCommand: null,
env: { JAVA_HOME: installation.javaHome },
signal: controller.signal,
});
decoderCleaned = false;
supervisor = new ProviderProcessSupervisor(
{
...launch,
ownsProcessLifetime: true,
cleanup: () => cleanupOwnedProcessGroup(launch.ownership),
},
{ maxDiagnosticBytes: 8 * 1024 * 1024 },
);
assert.ok(
await Promise.race([supervisor.waitForExit(120_000), interrupted]),
controller.signal.aborted
? "Swift decoder probe interrupted"
: "Swift decoder probe timed out",
);
assert.ok(
await supervisor.waitForOutputClose(1_000),
"Swift decoder probe output did not close",
);
const snapshot = supervisor.snapshot();
assert.equal(
snapshot.exitCode,
0,
`${snapshot.stdout.text}\n${snapshot.stderr.text}`,
);
assert.ok(
!snapshot.diagnosticTruncated,
"Swift decoder probe exceeded its diagnostic capacity",
);
} finally {
try {
if (supervisor !== undefined) {
const stopped = await supervisor.stop();
decoderCleaned = stopped.status === "verified-cleanup";
assert.ok(
decoderCleaned,
`Owned Ghidra cleanup incomplete; retained ${root}: ${JSON.stringify(stopped)}`,
);
}
} finally {
process.off("SIGINT", interrupt);
process.off("SIGTERM", interrupt);
}
}
}
@@ -0,0 +1,152 @@
// Producer regressions run inside a real, ephemeral Ghidra analysis of the
// source-owned AArch64 fixture. No target code is executed and no decoded JSON
// fixture substitutes for the Java decoder under test.
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.nio.file.Files;
import java.nio.file.Path;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import ghidra.app.util.headless.HeadlessScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Function;
import ghidra.program.model.listing.FunctionIterator;
import ghidra.program.model.listing.Instruction;
import ghidra.program.model.listing.InstructionIterator;
import ghidra.program.model.lang.Register;
import ghidra.program.model.symbol.Symbol;
import ghidra.program.model.symbol.SymbolIterator;
public class ReaSwiftDecoderRegressions extends HeadlessScript {
private ReaGhidraBridge decoder;
private Class<?> budgetClass;
@Override
public void run() throws Exception {
decoder = new ReaGhidraBridge();
setInherited(decoder, "currentProgram", currentProgram);
setInherited(decoder, "monitor", monitor);
setInherited(decoder, "sessionDefaultAddressSpace", currentProgram.getAddressFactory().getDefaultAddressSpace());
budgetClass = Class.forName("ReaGhidraBridge$SwiftLiteralBudget", true, decoder.getClass().getClassLoader());
Function copied = function("rea_swift_decoder_copied");
JsonArray copiedValues = literals(decode(copied, 65536));
require(copiedValues.size() > 0, "copied constant pair was not decoded");
for (var element : copiedValues) {
require(element.getAsJsonObject().get("value").getAsString().equals("ok"), "copied words invented text");
}
for (String name : new String[] {"select", "selected_copy", "padding", "utf8"}) {
Object result = decode(function("rea_swift_decoder_" + name), 65536);
require(literals(result).isEmpty(), name + " produced an invented Swift literal");
require(omissions(result).isEmpty(), name + " was confused with capacity exhaustion");
}
for (String name : new String[] {"select", "selected_copy"}) {
Function selected = function("rea_swift_decoder_" + name);
require((Boolean) invoke("selectedValue", new Class<?>[] {Function.class, Instruction.class, Register.class}, selected, returnInstruction(selected), currentProgram.getRegister("x0")),
name + " did not retain conditional-select provenance");
}
require(invoke("swiftLiteralLayout", new Class<?>[] {long.class, long.class}, 0x6261L, 0xe100000000000000L) == null,
"nonzero small-string padding was accepted");
require(invoke("swiftLiteralLayout", new Class<?>[] {long.class, long.class}, 0xc0L, 0xa100000000000000L) == null,
"malformed small-string UTF-8 was accepted");
rejectLarge("rea_swift_decoder_bad_terminator", 0xd000000000000003L);
rejectLarge("rea_swift_decoder_bad_large_utf8", 0x1000000000000001L);
// Zero covers provenance-state exhaustion; 8192 admits both register
// cache entries and then rejects the retained literal/text charge.
assertCapacityOmission(copied, 0);
assertCapacityOmission(copied, 8192);
require(getScriptArgs().length == 1, "Expected owned result path");
Files.writeString(Path.of(getScriptArgs()[0]), "REA_SWIFT_DECODER_SAFEGUARDS_OK\n");
}
private void assertCapacityOmission(Function function, long remaining) throws Exception {
Object exhausted = decode(function, remaining);
require(literals(exhausted).isEmpty(), "capacity failure published decoded small text at budget " + remaining);
JsonArray omitted = omissions(exhausted);
require(omitted.size() == 1, "capacity failure did not stop subsequent use sites at budget " + remaining);
JsonObject omission = omitted.get(0).getAsJsonObject();
require(omission.get("reason").getAsString().equals("capacity"), "capacity omission lost its reason");
require(!omission.has("value"), "omission claimed a retained literal value");
}
private Object decode(Function function, long remaining) throws Exception {
Constructor<?> constructor = budgetClass.getDeclaredConstructor(long.class);
constructor.setAccessible(true);
return invoke("swiftStringLiterals", new Class<?>[] {Function.class, budgetClass}, function, constructor.newInstance(remaining));
}
private void rejectLarge(String name, long word) throws Exception {
Address address = symbol(name).getAddress();
long object = 0x8000000000000000L | ((address.getOffset() - 0x20) & 0x00ffffffffffffffL);
Object layout = invoke("swiftLiteralLayout", new Class<?>[] {long.class, long.class}, word, object);
require(layout != null, "large negative fixture did not reach the memory-text decoder");
require(invoke("swiftLargeLiteralText", new Class<?>[] {layout.getClass()}, layout) == null,
name + " was accepted as literal text");
}
private Instruction returnInstruction(Function function) {
InstructionIterator instructions = currentProgram.getListing().getInstructions(function.getBody(), true);
while (instructions.hasNext()) {
Instruction instruction = instructions.next();
if (instruction.getMnemonicString().equalsIgnoreCase("ret")) return instruction;
}
throw new AssertionError("Fixture has no return instruction");
}
private Function function(String name) {
FunctionIterator functions = currentProgram.getFunctionManager().getFunctions(true);
Function found = null;
while (functions.hasNext()) {
Function candidate = functions.next();
if (!candidate.getName().equals(name) && !candidate.getName().equals("_" + name)) continue;
require(found == null, "ambiguous fixture function " + name);
found = candidate;
}
require(found != null, "missing fixture function " + name);
return found;
}
private Symbol symbol(String name) {
SymbolIterator symbols = currentProgram.getSymbolTable().getAllSymbols(true);
Symbol found = null;
while (symbols.hasNext()) {
Symbol candidate = symbols.next();
if (!candidate.getName().equals(name) && !candidate.getName().equals("_" + name)) continue;
require(found == null, "ambiguous fixture symbol " + name);
found = candidate;
}
require(found != null, "missing fixture symbol " + name);
return found;
}
private Object invoke(String name, Class<?>[] types, Object... arguments) throws Exception {
Method method = ReaGhidraBridge.class.getDeclaredMethod(name, types);
method.setAccessible(true);
return method.invoke(decoder, arguments);
}
private JsonArray literals(Object result) throws Exception { return array(result, "literals"); }
private JsonArray omissions(Object result) throws Exception { return array(result, "omissions"); }
private JsonArray array(Object result, String name) throws Exception {
Method method = result.getClass().getDeclaredMethod(name);
method.setAccessible(true);
return (JsonArray) method.invoke(result);
}
private void setInherited(Object target, String name, Object value) throws Exception {
for (Class<?> type = target.getClass(); type != null; type = type.getSuperclass()) {
try {
Field field = type.getDeclaredField(name);
field.setAccessible(true);
field.set(target, value);
return;
} catch (NoSuchFieldException nextOwner) { }
}
throw new AssertionError("Missing Ghidra state field " + name);
}
private static void require(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
}
@@ -0,0 +1,58 @@
/* Static-analysis fixture only. Never executed. Deliberate malformed ABI words
* cannot be produced by a well-formed Swift source literal. */
.text
.p2align 2
.globl _rea_swift_decoder_copied
_rea_swift_decoder_copied:
mov x5, #0x6b6f
movz x6, #0xe200, lsl #48
mov x0, x5
mov x1, x6
stp x0, x1, [sp, #-16]!
ldp x0, x1, [sp], #16
ret
.p2align 2
.globl _rea_swift_decoder_select
_rea_swift_decoder_select:
mov x2, #0x61
mov x3, #0x6262
movz x6, #0xe100, lsl #48
movz x7, #0xe200, lsl #48
cmp w4, #0
csel x0, x2, x3, eq
csel x1, x6, x7, eq
ret
.p2align 2
.globl _rea_swift_decoder_selected_copy
_rea_swift_decoder_selected_copy:
mov x2, #0x61
mov x3, #0x62
cmp w4, #0
csel x5, x2, x3, eq
mov x0, x5
movz x1, #0xe100, lsl #48
ret
.p2align 2
.globl _rea_swift_decoder_padding
_rea_swift_decoder_padding:
mov x0, #0x6261
movz x1, #0xe100, lsl #48
ret
.p2align 2
.globl _rea_swift_decoder_utf8
_rea_swift_decoder_utf8:
mov x0, #0xc0
movz x1, #0xa100, lsl #48
ret
.section __DATA,__const
.globl _rea_swift_decoder_bad_terminator
_rea_swift_decoder_bad_terminator:
.byte 0x61, 0x62, 0x63, 0x78
.globl _rea_swift_decoder_bad_large_utf8
_rea_swift_decoder_bad_large_utf8:
.byte 0xc0, 0