diff --git a/bridge/nativeaot/NativeAotExtension.java b/bridge/nativeaot/NativeAotExtension.java index 7936b22c24..69d7c38ece 100644 --- a/bridge/nativeaot/NativeAotExtension.java +++ b/bridge/nativeaot/NativeAotExtension.java @@ -78,14 +78,9 @@ public final class NativeAotExtension { new FrozenObjectAnnotator(program, manager).analyze(located.directory(), scan.getPointerLocations(), monitor, log); } monitor.checkCancelled(); - report.addProperty("method_tables", manager.getMethodTableCount()); JsonObject coverage = auditFrozenObjects(program, scan.getPointerLocations(), monitor); - report.add("coverage", coverage); if (coverage.get("frozen_objects_annotated").getAsInt() != coverage.get("frozen_object_candidates").getAsInt()) diagnostics.add("Some frozen-object candidates lack committed instance annotations; inspect coverage counts and Ghidra diagnostics."); - report.addProperty("status", diagnostics.isEmpty() ? "complete" : "partial"); - report.add("reason", JsonNull.INSTANCE); - report.add("derived_memory", derivedRange.deepCopy()); JsonArray tables = new JsonArray(); for (var mt : manager.getMethodTables()) { monitor.checkCancelled(); @@ -94,6 +89,13 @@ public final class NativeAotExtension { identity.addProperty("type", mt.getMTType().getPathName()); tables.add(identity); } + // Publish recovery facts only after every fallible recovery and audit step. + // A failed transaction is rolled back, so it cannot report committed metadata. + report.addProperty("method_tables", manager.getMethodTableCount()); + report.add("coverage", coverage); + report.addProperty("status", diagnostics.isEmpty() ? "complete" : "partial"); + report.add("reason", JsonNull.INSTANCE); + report.add("derived_memory", derivedRange.deepCopy()); report.add("types", tables); commit = true; return finish(log); diff --git a/src/ghidra/extensions/nativeaot/NativeAotProducer.test.ts b/src/ghidra/extensions/nativeaot/NativeAotProducer.test.ts new file mode 100644 index 0000000000..32cf250260 --- /dev/null +++ b/src/ghidra/extensions/nativeaot/NativeAotProducer.test.ts @@ -0,0 +1,41 @@ +import { expect, it } from "vitest"; +import { jsonObjectSchema } from "../../../domain/jsonValue.js"; +import { runOwnedCommand } from "../../../process/OwnedCommand.js"; +import { validateNativeAotReport } from "./NativeAotReport.js"; + +it.skipIf(!process.env.JAVA_HOME || !process.env.GSON_JAR)( + "accepts transaction reports from the Java producer with controlled APIs", + async () => { + const output = await runOwnedCommand( + { + command: "python3", + arguments: ["-S", "tests/fixtures/nativeaot-producer/run.py", "--json"], + runId: "rea-nativeaot-producer-test", + expectedCommand: null, + }, + { timeoutMs: 75_000, diagnosticBytes: 1024 * 1024 }, + ); + const reports = JSON.parse(output.stdout.text) as Array<{ + status: "failed" | "partial" | "complete"; + reason: string | null; + [key: string]: unknown; + }>; + expect(reports.map(({ status }) => status)).toEqual([ + "failed", + "failed", + "complete", + "partial", + ]); + for (const report of reports) + expect( + validateNativeAotReport({ + id: "nativeaot", + sha256: "0".repeat(64), + status: report.status, + reason: report.reason, + result: jsonObjectSchema.parse(report), + }), + ).toBeNull(); + }, + 90_000, +); diff --git a/tests/fixtures/nativeaot-producer/NativeAotProducerProbe.java b/tests/fixtures/nativeaot-producer/NativeAotProducerProbe.java new file mode 100644 index 0000000000..293a592ca0 --- /dev/null +++ b/tests/fixtures/nativeaot-producer/NativeAotProducerProbe.java @@ -0,0 +1,64 @@ +package rea.extensions.nativeaot; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import ghidra.program.model.listing.Program; +import ghidra.util.exception.CancelledException; +import ghidra.util.task.TaskMonitor; +import nativeaot.objectmodel.MethodTableManager; + +/** Execute the actual producer against controlled APIs, not a real Ghidra runtime. */ +public final class NativeAotProducerProbe { + private static void require(boolean value, String message) { + if (!value) throw new AssertionError(message); + } + private static JsonObject failure(boolean audit) throws Exception { + MethodTableManager.failAudit = audit; + MethodTableManager.failTypes = !audit; + Program program = new Program(); + JsonObject report = new NativeAotExtension().analyze(program, new TaskMonitor()); + require(!program.committed && program.ends == 1, "failure must roll back exactly once"); + require(report.get("status").getAsString().equals("failed"), "expected failed status"); + require(report.get("method_tables").getAsInt() == 0, "rolled-back tables must not be reported"); + for (String key : new String[]{"types", "coverage", "derived_memory"}) + require(!report.has(key), "rolled-back report contains " + key); + require(report.get("reason").getAsString().contains(audit ? "audit failure" : "type failure"), "failure reason lost"); + MethodTableManager.failAudit = MethodTableManager.failTypes = false; + return report; + } + private static JsonObject success(boolean partial) throws Exception { + Program.partial = partial; + Program program = new Program(); + JsonObject report = new NativeAotExtension().analyze(program, new TaskMonitor()); + require(program.committed && program.ends == 1, "successful analysis must commit exactly once"); + require(report.get("status").getAsString().equals(partial ? "partial" : "complete"), "wrong coverage status"); + require(report.get("method_tables").getAsInt() == 1, "missing method table"); + for (String key : new String[]{"types", "coverage", "derived_memory"}) + require(report.has(key), "recovery missing " + key); + Program.partial = false; + return report; + } + public static void main(String[] args) throws Exception { + if (args.length > 0 && !args[0].equals("--json")) { + failure(args[0].equals("audit")); + return; + } + JsonArray reports = new JsonArray(); + reports.add(failure(true)); + reports.add(failure(false)); + reports.add(success(false)); + reports.add(success(true)); + for (int cancelAt : new int[]{1, 4}) { + Program program = new Program(); + TaskMonitor cancelled = new TaskMonitor(); + cancelled.cancelAt = cancelAt; + try { + new NativeAotExtension().analyze(program, cancelled); + throw new AssertionError("expected cancellation"); + } catch (CancelledException expected) { + require(!program.committed && program.ends == 1, "cancellation must roll back exactly once"); + } + } + System.out.println(args.length > 0 ? reports : "NativeAOT producer transaction contract passed (controlled API doubles)"); + } +} diff --git a/tests/fixtures/nativeaot-producer/README.md b/tests/fixtures/nativeaot-producer/README.md new file mode 100644 index 0000000000..4e6a3913af --- /dev/null +++ b/tests/fixtures/nativeaot-producer/README.md @@ -0,0 +1,16 @@ +# NativeAOT producer transaction probe + +This compiles the shipped Java producer with controlled Ghidra and upstream API +doubles and real Gson. It checks late failures, success, partial recovery, and +cancellation against the transaction and report contract. It does not test the +real Ghidra APIs or engine. With JDK 21+ and a Gson jar already available: + +```sh +JAVA_HOME=/path/to/jdk GSON_JAR=/path/to/gson.jar npm run test:local -- src/ghidra/extensions/nativeaot/NativeAotProducer.test.ts +``` + +The test is skipped without both variables. To run the Java probe directly, +use `python3 tests/fixtures/nativeaot-producer/run.py`. Add `--json` to emit the +four reports checked by the TypeScript validator. For a baseline comparison, +set `REA_NATIVEAOT_PRODUCER_SOURCE` to another `NativeAotExtension.java` and pass +`audit` or `types` to select one failure case. Subprocesses have 30-second limits. diff --git a/tests/fixtures/nativeaot-producer/run.py b/tests/fixtures/nativeaot-producer/run.py new file mode 100644 index 0000000000..86e95afef6 --- /dev/null +++ b/tests/fixtures/nativeaot-producer/run.py @@ -0,0 +1,19 @@ +"""Compile the shipped extension with controlled API doubles and run its transaction contract. + +Requires JAVA_HOME (JDK 21+) and GSON_JAR. This does not validate real Ghidra APIs. +""" +import os +from pathlib import Path +import subprocess +import sys +import tempfile + +ROOT = Path(__file__).resolve().parents[3] +JAVA = Path(os.environ['JAVA_HOME']) / 'bin' +GSON = os.environ['GSON_JAR'] +with tempfile.TemporaryDirectory(prefix='rea-nativeaot-java-') as temp: + root = Path(temp) + sources = [str(p) for p in (Path(__file__).parent / "stubs").rglob("*.java")] + sources += [os.environ.get("REA_NATIVEAOT_PRODUCER_SOURCE", str(ROOT / "bridge/nativeaot/NativeAotExtension.java")), str(Path(__file__).with_name('NativeAotProducerProbe.java'))] + subprocess.run([str(JAVA / 'javac'), '--release', '21', '-cp', GSON, '-d', str(root), *sources], check=True, timeout=30) + subprocess.run([str(JAVA / 'java'), '-cp', os.pathsep.join([str(root), GSON]), 'rea.extensions.nativeaot.NativeAotProducerProbe', *sys.argv[1:]], check=True, timeout=30) diff --git a/tests/fixtures/nativeaot-producer/stubs/ghidra/app/util/importer/MessageLog.java b/tests/fixtures/nativeaot-producer/stubs/ghidra/app/util/importer/MessageLog.java new file mode 100644 index 0000000000..f8928ddca0 --- /dev/null +++ b/tests/fixtures/nativeaot-producer/stubs/ghidra/app/util/importer/MessageLog.java @@ -0,0 +1,7 @@ +package ghidra.app.util.importer; +public class MessageLog { + public void appendMsg(String a,String b) {} + public String toString() { + return ""; +} +} diff --git a/tests/fixtures/nativeaot-producer/stubs/ghidra/program/model/address/Address.java b/tests/fixtures/nativeaot-producer/stubs/ghidra/program/model/address/Address.java new file mode 100644 index 0000000000..2364f48701 --- /dev/null +++ b/tests/fixtures/nativeaot-producer/stubs/ghidra/program/model/address/Address.java @@ -0,0 +1,9 @@ +package ghidra.program.model.address; +public class Address implements Comparable
{ + public long value; +public Address(long v) {value=v;} + public long getOffset(){return value;} + public Address getNewAddress(long n){return new Address(n);} + public Address add(long n){return new Address(value+n);} + public int compareTo(Address a){return Long.compare(value,a.value);} +} diff --git a/tests/fixtures/nativeaot-producer/stubs/ghidra/program/model/address/AddressSet.java b/tests/fixtures/nativeaot-producer/stubs/ghidra/program/model/address/AddressSet.java new file mode 100644 index 0000000000..7fa25f1cd3 --- /dev/null +++ b/tests/fixtures/nativeaot-producer/stubs/ghidra/program/model/address/AddressSet.java @@ -0,0 +1,5 @@ +package ghidra.program.model.address; +public class AddressSet { + public AddressSet(Address a,Address b) {} + public Object getFirstRange(){return null;} +} diff --git a/tests/fixtures/nativeaot-producer/stubs/ghidra/program/model/data/DataType.java b/tests/fixtures/nativeaot-producer/stubs/ghidra/program/model/data/DataType.java new file mode 100644 index 0000000000..ae536cb631 --- /dev/null +++ b/tests/fixtures/nativeaot-producer/stubs/ghidra/program/model/data/DataType.java @@ -0,0 +1,4 @@ +package ghidra.program.model.data; +public class DataType { + public String getPathName(){return "T";} +} diff --git a/tests/fixtures/nativeaot-producer/stubs/ghidra/program/model/listing/Program.java b/tests/fixtures/nativeaot-producer/stubs/ghidra/program/model/listing/Program.java new file mode 100644 index 0000000000..aa0df6071e --- /dev/null +++ b/tests/fixtures/nativeaot-producer/stubs/ghidra/program/model/listing/Program.java @@ -0,0 +1,26 @@ +package ghidra.program.model.listing; +import ghidra.program.model.address.Address; +import ghidra.program.model.data.DataType; +public class Program { + public static boolean partial; +public boolean committed; +public int ends; +public LanguageID getLanguageID(){return new LanguageID();} + public int startTransaction(String s){return 1;} + public void endTransaction(int id,boolean commit){committed=commit; +ends++;} + public Memory getMemory(){return new Memory();} + public Listing getListing(){return new Listing();} + public static class LanguageID { + public String getIdAsString(){return "x86:LE:64";} +} + public static class Memory { + public int getBytes(Address a,byte[] b,int o,int n){return n;} + public long getLong(Address a){return 1;} +} + public static class Listing { + public Data getDataAt(Address a){return new Data();} +} + public static class Data { + public DataType getDataType(){return partial ? new DataType(){public String getPathName(){return "other";}} : new DataType();} +} } diff --git a/tests/fixtures/nativeaot-producer/stubs/ghidra/util/exception/CancelledException.java b/tests/fixtures/nativeaot-producer/stubs/ghidra/util/exception/CancelledException.java new file mode 100644 index 0000000000..af587fb351 --- /dev/null +++ b/tests/fixtures/nativeaot-producer/stubs/ghidra/util/exception/CancelledException.java @@ -0,0 +1,2 @@ +package ghidra.util.exception; +public class CancelledException extends Exception {} diff --git a/tests/fixtures/nativeaot-producer/stubs/ghidra/util/task/TaskMonitor.java b/tests/fixtures/nativeaot-producer/stubs/ghidra/util/task/TaskMonitor.java new file mode 100644 index 0000000000..eef3145cc8 --- /dev/null +++ b/tests/fixtures/nativeaot-producer/stubs/ghidra/util/task/TaskMonitor.java @@ -0,0 +1,9 @@ +package ghidra.util.task; +import ghidra.util.exception.CancelledException; +public class TaskMonitor { +public int cancelAt, checks; +public void checkCancelled() throws CancelledException { + checks++; +if(checks == cancelAt) throw new CancelledException(); +} +} diff --git a/tests/fixtures/nativeaot-producer/stubs/nativeaot/objectmodel/FrozenObjectAnnotator.java b/tests/fixtures/nativeaot-producer/stubs/nativeaot/objectmodel/FrozenObjectAnnotator.java new file mode 100644 index 0000000000..c2429371ed --- /dev/null +++ b/tests/fixtures/nativeaot-producer/stubs/nativeaot/objectmodel/FrozenObjectAnnotator.java @@ -0,0 +1,10 @@ +package nativeaot.objectmodel; +import ghidra.program.model.address.Address; +import ghidra.program.model.listing.Program; +import ghidra.util.task.TaskMonitor; +import ghidra.app.util.importer.MessageLog; +import nativeaot.rtr.ReadyToRunDirectory; +public class FrozenObjectAnnotator { +public FrozenObjectAnnotator(Program p,MethodTableManager m) {} + public void analyze(ReadyToRunDirectory d,Address[] a,TaskMonitor m,MessageLog l) {} +} diff --git a/tests/fixtures/nativeaot-producer/stubs/nativeaot/objectmodel/MethodTableCrawler.java b/tests/fixtures/nativeaot-producer/stubs/nativeaot/objectmodel/MethodTableCrawler.java new file mode 100644 index 0000000000..e0fe8d9158 --- /dev/null +++ b/tests/fixtures/nativeaot-producer/stubs/nativeaot/objectmodel/MethodTableCrawler.java @@ -0,0 +1,9 @@ +package nativeaot.objectmodel; +import ghidra.program.model.listing.Program; +import ghidra.util.task.TaskMonitor; +import ghidra.app.util.importer.MessageLog; +import nativeaot.rehydration.MetadataRehydratorNet80.Scan; +public class MethodTableCrawler { + public MethodTableCrawler(MethodTableManager m,Program p,Scan s) {} + public void analyze(TaskMonitor m,MessageLog l) {} +} diff --git a/tests/fixtures/nativeaot-producer/stubs/nativeaot/objectmodel/MethodTableManager.java b/tests/fixtures/nativeaot-producer/stubs/nativeaot/objectmodel/MethodTableManager.java new file mode 100644 index 0000000000..8ad8f3d734 --- /dev/null +++ b/tests/fixtures/nativeaot-producer/stubs/nativeaot/objectmodel/MethodTableManager.java @@ -0,0 +1,21 @@ +package nativeaot.objectmodel; +import java.util.List; +import ghidra.program.model.address.Address; +import ghidra.program.model.data.DataType; +import ghidra.program.model.listing.Program; +import nativeaot.rtr.ReadyToRunDirectory; +public class MethodTableManager { + public static boolean failAudit,failTypes; +public static MethodTableManager createForDirectory(Program p,ReadyToRunDirectory d){return new MethodTableManager();} + public Object getObjectMT(){return this;} + public Object getStringMT(){return this;} + public int getMethodTableCount(){return 1;} + public Table getMethodTable(long n){if(failAudit) throw new IllegalStateException("audit failure"); +return new Table();} + public List getMethodTables(){return List.of(new Table());} + public static class Table { + public Address getAddress(){return new Address(1);} + public DataType getMTType(){if(failTypes) throw new IllegalStateException("type failure"); +return new DataType();} + public DataType getInstanceType(){return new DataType();} +} } diff --git a/tests/fixtures/nativeaot-producer/stubs/nativeaot/rehydration/MetadataRehydratorNet80.java b/tests/fixtures/nativeaot-producer/stubs/nativeaot/rehydration/MetadataRehydratorNet80.java new file mode 100644 index 0000000000..73d69e89ae --- /dev/null +++ b/tests/fixtures/nativeaot-producer/stubs/nativeaot/rehydration/MetadataRehydratorNet80.java @@ -0,0 +1,15 @@ +package nativeaot.rehydration; +import ghidra.program.model.address.Address; +import ghidra.program.model.listing.Program; +import ghidra.util.task.TaskMonitor; +import ghidra.app.util.importer.MessageLog; +public class MetadataRehydratorNet80 { + public Scan rehydrate(Program p,Object r,TaskMonitor m,MessageLog l){return new Scan();} + public static class Scan { + public Range getScanningRange(){return new Range();} + public Address[] getPointerLocations(){return new Address[]{new Address(1)};} +} + public static class Range { + public long getLength(){return 2;} + public Address getMinAddress(){return new Address(1);} +} } diff --git a/tests/fixtures/nativeaot-producer/stubs/nativeaot/rtr/ReadyToRunDirectory.java b/tests/fixtures/nativeaot-producer/stubs/nativeaot/rtr/ReadyToRunDirectory.java new file mode 100644 index 0000000000..ae7b34bd42 --- /dev/null +++ b/tests/fixtures/nativeaot-producer/stubs/nativeaot/rtr/ReadyToRunDirectory.java @@ -0,0 +1,6 @@ +package nativeaot.rtr; +public class ReadyToRunDirectory { + public int getMajorVersion(){return 9;} + public int getMinorVersion(){return 1;} + public ReadyToRunSection getSectionByType(int t){return new ReadyToRunSection();} +} diff --git a/tests/fixtures/nativeaot-producer/stubs/nativeaot/rtr/ReadyToRunSection.java b/tests/fixtures/nativeaot-producer/stubs/nativeaot/rtr/ReadyToRunSection.java new file mode 100644 index 0000000000..6707e9b79d --- /dev/null +++ b/tests/fixtures/nativeaot-producer/stubs/nativeaot/rtr/ReadyToRunSection.java @@ -0,0 +1,8 @@ +package nativeaot.rtr; +import ghidra.program.model.address.Address; +public class ReadyToRunSection { + public static final int DEHYDRATED_DATA=1,FROZEN_OBJECT_REGION=2; +public long getStart(){return 1;} + public long getEnd(){return 3;} + public Address[] getPointersInSection(Address[] a){return a;} +} diff --git a/tests/fixtures/nativeaot-producer/stubs/rea/extensions/nativeaot/NativeAotDirectory.java b/tests/fixtures/nativeaot-producer/stubs/rea/extensions/nativeaot/NativeAotDirectory.java new file mode 100644 index 0000000000..cf8b0cd840 --- /dev/null +++ b/tests/fixtures/nativeaot-producer/stubs/rea/extensions/nativeaot/NativeAotDirectory.java @@ -0,0 +1,10 @@ +package rea.extensions.nativeaot; +import ghidra.program.model.address.Address; +import ghidra.program.model.listing.Program; +import ghidra.util.task.TaskMonitor; +import ghidra.app.util.importer.MessageLog; +import nativeaot.rtr.ReadyToRunDirectory; +final class NativeAotDirectory { + record Located(Address address,ReadyToRunDirectory directory,String discovery) {} + static Located locate(Program p,TaskMonitor m,MessageLog l){return new Located(new Address(1),new ReadyToRunDirectory(),"symbol");} +} diff --git a/tests/fixtures/nativeaot-producer/stubs/rea/extensions/nativeaot/NativeAotMethodConventions.java b/tests/fixtures/nativeaot-producer/stubs/rea/extensions/nativeaot/NativeAotMethodConventions.java new file mode 100644 index 0000000000..ee5f2f0063 --- /dev/null +++ b/tests/fixtures/nativeaot-producer/stubs/rea/extensions/nativeaot/NativeAotMethodConventions.java @@ -0,0 +1,8 @@ +package rea.extensions.nativeaot; +import ghidra.program.model.listing.Program; +import ghidra.util.task.TaskMonitor; +import nativeaot.objectmodel.MethodTableManager; +final class NativeAotMethodConventions { + static Object capture(Program p,TaskMonitor m){return null;} + static void restore(Program p,MethodTableManager a,Object c,TaskMonitor m) {} +} diff --git a/tests/fixtures/nativeaot-producer/stubs/rea/extensions/nativeaot/NativeAotTypeMetadata.java b/tests/fixtures/nativeaot-producer/stubs/rea/extensions/nativeaot/NativeAotTypeMetadata.java new file mode 100644 index 0000000000..30f447bc3d --- /dev/null +++ b/tests/fixtures/nativeaot-producer/stubs/rea/extensions/nativeaot/NativeAotTypeMetadata.java @@ -0,0 +1,10 @@ +package rea.extensions.nativeaot; +import com.google.gson.JsonObject; +import ghidra.program.model.address.Address; +import ghidra.program.model.data.DataType; +import ghidra.program.model.listing.Program; +import ghidra.util.task.TaskMonitor; +import nativeaot.objectmodel.MethodTableManager; +final class NativeAotTypeMetadata { + static JsonObject describe(Program p,MethodTableManager m,NativeAotDirectory.Located l,DataType t,JsonObject r,JsonObject d,TaskMonitor x){return null;} +}