Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ec8267bee | ||
|
|
d3a39a9376 | ||
|
|
5ddf8766b8 | ||
|
|
e5bc4e85ec | ||
|
|
38221cb12e | ||
|
|
3749ccec47 | ||
|
|
a67dbf6a48 | ||
|
|
bd3a9bbbaf | ||
|
|
74050978be | ||
|
|
b212e92f9b | ||
|
|
990e1ad8c1 | ||
|
|
76d8f68cb9 | ||
|
|
fe574c0c9f | ||
|
|
3a7a3074e3 |
@@ -1,5 +1,13 @@
|
|||||||
# JavaScript and Electron application artifacts
|
# JavaScript and Electron application artifacts
|
||||||
|
|
||||||
|
For captured or bundled JavaScript that needs readable source recovery, use
|
||||||
|
`recover_javascript_sources` with an absolute `path` and absent
|
||||||
|
`output_directory`. Supply related runtime/shared/lazy files in
|
||||||
|
`additional_paths` when advertised, then analyze the returned `analysis_input`
|
||||||
|
directory together. Each input retains its own snapshot, digest and byte-range
|
||||||
|
provenance; inputs and recovered code are never executed. Recovery requires a
|
||||||
|
caller-supplied Wakaru on Linux x64; see [the recovery guide](https://github.com/morluto/rea/blob/main/docs/javascript-recovery.md).
|
||||||
|
|
||||||
Use `analyze_javascript_application` directly on the operator-supplied ASAR or
|
Use `analyze_javascript_application` directly on the operator-supplied ASAR or
|
||||||
extracted tree. When size is unknown or large and the connected schema advertises
|
extracted tree. When size is unknown or large and the connected schema advertises
|
||||||
`detail`, request `detail: "summary"`. This returns an `inspect_analysis_view`
|
`detail`, request `detail: "summary"`. This returns an `inspect_analysis_view`
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
**See a feature you like. Understand how it works, down to the binary level.**
|
**See a feature you like. Understand how it works, down to the binary level.**
|
||||||
|
|
||||||
[](https://www.npmjs.com/package/rea-agents)
|
[](https://www.npmjs.com/package/rea-agents)
|
||||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
|
||||||
[](docs/mcp-contracts.md#generated-catalog)
|
[](docs/mcp-contracts.md#generated-catalog)
|
||||||
[](#what-you-can-analyze)
|
[](#what-you-can-analyze)
|
||||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||||
|
|||||||
+389
-184
@@ -2004,9 +2004,20 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
|||||||
);
|
);
|
||||||
JumpTable.LoadTable[] loadTables = jumpTable.getLoadTables();
|
JumpTable.LoadTable[] loadTables = jumpTable.getLoadTables();
|
||||||
RecoveredAArch64RelativeTable recoveredAArch64 =
|
RecoveredAArch64RelativeTable recoveredAArch64 =
|
||||||
loadTables.length == 0
|
recoverAArch64RelativeTable(jumpTable);
|
||||||
? recoverAArch64RelativeTable(jumpTable)
|
if (recoveredAArch64 != null && loadTables.length > 0) {
|
||||||
: null;
|
// A decoded table only adds evidence when Ghidra's own load table
|
||||||
|
// describes the same bytes; any disagreement keeps Ghidra's view.
|
||||||
|
boolean agrees = false;
|
||||||
|
for (JumpTable.LoadTable loadTable : loadTables) {
|
||||||
|
if (loadTable.getAddress().equals(recoveredAArch64.tableAddress) &&
|
||||||
|
loadTable.getSize() == recoveredAArch64.entrySize &&
|
||||||
|
loadTable.getNum() == recoveredAArch64.mappings.size()) {
|
||||||
|
agrees = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!agrees) recoveredAArch64 = null;
|
||||||
|
}
|
||||||
List<Address> referencedDataAddresses = loadTables.length == 0
|
List<Address> referencedDataAddresses = loadTables.length == 0
|
||||||
? dispatchPathDataReferences(jumpTable)
|
? dispatchPathDataReferences(jumpTable)
|
||||||
: List.of();
|
: List.of();
|
||||||
@@ -2040,7 +2051,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
|||||||
source.add("evidence", evidence);
|
source.add("evidence", evidence);
|
||||||
dataSources.add(source);
|
dataSources.add(source);
|
||||||
}
|
}
|
||||||
if (recoveredAArch64 != null) {
|
if (recoveredAArch64 != null && loadTables.length == 0) {
|
||||||
JsonObject source = new JsonObject();
|
JsonObject source = new JsonObject();
|
||||||
source.addProperty(
|
source.addProperty(
|
||||||
"address",
|
"address",
|
||||||
@@ -2055,9 +2066,16 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
|||||||
inferenceEvidence(
|
inferenceEvidence(
|
||||||
"jump-table",
|
"jump-table",
|
||||||
"ghidra-instruction-and-memory",
|
"ghidra-instruction-and-memory",
|
||||||
"Decoded " + recoveredAArch64.mappings.size() +
|
"Decoded " + recoveredAArch64.mappings.size() + " " +
|
||||||
|
(recoveredAArch64.signedEntries ? "signed" : "unsigned") +
|
||||||
" entries of size " + recoveredAArch64.entrySize + " bytes from " + canonicalAddress(recoveredAArch64.tableAddress) +
|
" entries of size " + recoveredAArch64.entrySize + " bytes from " + canonicalAddress(recoveredAArch64.tableAddress) +
|
||||||
"; the AArch64 LDRB-or-LDRH/ADR/ADD/BR chain uses a selector guarded by an unsigned upper-bound branch."
|
"; the AArch64 ADRP/ADD table, indexed load, ADR base, ADD" +
|
||||||
|
(recoveredAArch64.offsetShift == 0 ? "" : " LSL #" + recoveredAArch64.offsetShift) +
|
||||||
|
"/BR chain uses a selector bounded by CMP #" + recoveredAArch64.compareImmediate +
|
||||||
|
" and " + recoveredAArch64.guardMnemonic + " on one straight-line path" +
|
||||||
|
(recoveredAArch64.nullTarget == null
|
||||||
|
? "."
|
||||||
|
: "; zero entries are routed by CBZ to " + canonicalAddress(recoveredAArch64.nullTarget) + ".")
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
source.add("evidence", evidence);
|
source.add("evidence", evidence);
|
||||||
@@ -2096,6 +2114,33 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
|||||||
result.add("data_sources", dataSources);
|
result.add("data_sources", dataSources);
|
||||||
Address[] targets = jumpTable.getCases();
|
Address[] targets = jumpTable.getCases();
|
||||||
TypedJumpTableCases typedCases = typedJumpTableCases(jumpTable, caseTokens);
|
TypedJumpTableCases typedCases = typedJumpTableCases(jumpTable, caseTokens);
|
||||||
|
// Table indices identify the register bounded by CMP. Typed labels may
|
||||||
|
// instead identify a selector before normalization (for example, SUB
|
||||||
|
// index, selector, #10). Do not merge those distinct value identities.
|
||||||
|
if (recoveredAArch64 != null && recoveredAArch64.priorSelectorDefinition != null) {
|
||||||
|
typedCases.limitations.add(
|
||||||
|
"REA decoded the relative table, but the compared selector has an earlier register definition at " +
|
||||||
|
canonicalAddress(recoveredAArch64.priorSelectorDefinition) + "; its table indices are not proven to use the typed switch selector's case values. Ghidra's typed labels are retained and additional case labels remain unknown."
|
||||||
|
);
|
||||||
|
recoveredAArch64 = null;
|
||||||
|
}
|
||||||
|
if (recoveredAArch64 != null) {
|
||||||
|
for (Map.Entry<Long, Address> typed : typedCases.cases.entrySet()) {
|
||||||
|
long value = typed.getKey();
|
||||||
|
if (value < 0 || value >= recoveredAArch64.mappings.size()) {
|
||||||
|
typedCases.limitations.add("Typed case labels and decoded table indices do not use a verified common selector; Ghidra's typed labels are retained.");
|
||||||
|
recoveredAArch64 = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
RecoveredAArch64Mapping decoded = recoveredAArch64.mappings.get((int) value);
|
||||||
|
boolean divertedZero = decoded.nullEntry && typed.getValue().equals(recoveredAArch64.unreachableZeroTarget);
|
||||||
|
if (!typed.getValue().equals(decoded.targetAddress) && !divertedZero) {
|
||||||
|
typedCases.limitations.add("Typed case evidence and the decoded relative table disagree on a destination; Ghidra's typed labels are retained.");
|
||||||
|
recoveredAArch64 = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
JsonArray mappings = new JsonArray();
|
JsonArray mappings = new JsonArray();
|
||||||
JsonArray defaultTargets = new JsonArray();
|
JsonArray defaultTargets = new JsonArray();
|
||||||
for (Address target : typedCases.defaults) {
|
for (Address target : typedCases.defaults) {
|
||||||
@@ -2103,27 +2148,30 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
|||||||
}
|
}
|
||||||
Set<Address> coveredTargets = new HashSet<>(typedCases.cases.values());
|
Set<Address> coveredTargets = new HashSet<>(typedCases.cases.values());
|
||||||
coveredTargets.addAll(typedCases.defaults);
|
coveredTargets.addAll(typedCases.defaults);
|
||||||
|
if (recoveredAArch64 != null && recoveredAArch64.unreachableZeroTarget != null) {
|
||||||
|
Address unreachable = recoveredAArch64.unreachableZeroTarget;
|
||||||
|
coveredTargets.add(unreachable);
|
||||||
|
for (RecoveredAArch64Mapping recovered : recoveredAArch64.mappings) {
|
||||||
|
if (recovered.nullEntry && unreachable.equals(typedCases.cases.get((long) recovered.tableIndex))) {
|
||||||
|
typedCases.cases.remove((long) recovered.tableIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
typedCases.limitations.add(
|
||||||
|
"Ghidra recovered destination " + canonicalAddress(unreachable) +
|
||||||
|
" as the zero-offset decode of the relative table, but the CBZ before the dispatch diverts zero entries to " +
|
||||||
|
canonicalAddress(recoveredAArch64.nullTarget) + ", so the indirect branch never reaches it for an in-range case."
|
||||||
|
);
|
||||||
|
}
|
||||||
if (recoveredAArch64 != null) {
|
if (recoveredAArch64 != null) {
|
||||||
for (int index = 0; index < recoveredAArch64.mappings.size(); index += 1) {
|
for (int index = 0; index < recoveredAArch64.mappings.size(); index += 1) {
|
||||||
monitor.checkCancelled();
|
monitor.checkCancelled();
|
||||||
RecoveredAArch64Mapping recovered = recoveredAArch64.mappings.get(index);
|
RecoveredAArch64Mapping recovered = recoveredAArch64.mappings.get(index);
|
||||||
if (
|
if (
|
||||||
typedCases.unknownTargets.contains(recovered.targetAddress) ||
|
typedCases.unknownTargets.contains(recovered.targetAddress) ||
|
||||||
typedCases.ambiguousValues.contains((long) recovered.caseValue)
|
typedCases.ambiguousValues.contains((long) recovered.tableIndex)
|
||||||
) continue;
|
) continue;
|
||||||
Address typedTarget = typedCases.cases.get((long) recovered.caseValue);
|
|
||||||
if (typedTarget != null && !typedTarget.equals(recovered.targetAddress)) {
|
|
||||||
typedCases.cases.remove((long) recovered.caseValue);
|
|
||||||
typedCases.ambiguousValues.add((long) recovered.caseValue);
|
|
||||||
typedCases.unknownTargets.add(typedTarget);
|
|
||||||
typedCases.unknownTargets.add(recovered.targetAddress);
|
|
||||||
typedCases.limitations.add(
|
|
||||||
"Typed case evidence and the verified relative table disagree on a destination; both destinations remain unknown for that case."
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
JsonObject mapping = new JsonObject();
|
JsonObject mapping = new JsonObject();
|
||||||
mapping.addProperty("case_value", recovered.caseValue);
|
mapping.addProperty("case_value", recovered.tableIndex);
|
||||||
String targetAddress = canonicalAddress(recovered.targetAddress);
|
String targetAddress = canonicalAddress(recovered.targetAddress);
|
||||||
mapping.addProperty("target_address", targetAddress);
|
mapping.addProperty("target_address", targetAddress);
|
||||||
mapping.addProperty("confidence", "high");
|
mapping.addProperty("confidence", "high");
|
||||||
@@ -2132,14 +2180,18 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
|||||||
inferenceEvidence(
|
inferenceEvidence(
|
||||||
"jump-table",
|
"jump-table",
|
||||||
"ghidra-instruction-and-memory",
|
"ghidra-instruction-and-memory",
|
||||||
"Case value " + recovered.caseValue + (recoveredAArch64.entrySize == 1 ? " indexes byte " : " indexes halfword at byte offset ") +
|
"Case value " + recovered.tableIndex + (recoveredAArch64.entrySize == 1 ? " indexes byte " : " indexes halfword at byte offset ") +
|
||||||
(recovered.caseValue * recoveredAArch64.entrySize) + " at " + canonicalAddress(recoveredAArch64.tableAddress) +
|
(recovered.tableIndex * recoveredAArch64.entrySize) + " at " + canonicalAddress(recoveredAArch64.tableAddress) +
|
||||||
"; the loaded unsigned entry selects a four-byte instruction offset from " +
|
(recovered.nullEntry
|
||||||
canonicalAddress(recoveredAArch64.branchBase) + ", reaching " + targetAddress + "."
|
? "; the entry is zero, so the CBZ before the dispatch branches to " + targetAddress + "."
|
||||||
|
: "; the loaded " + (recoveredAArch64.signedEntries ? "signed" : "unsigned") +
|
||||||
|
" entry " + recovered.encodedEntry + " selects a " +
|
||||||
|
(recoveredAArch64.offsetShift == 2 ? "four-byte instruction" : "byte") +
|
||||||
|
" offset from " + canonicalAddress(recoveredAArch64.branchBase) + ", reaching " + targetAddress + ".")
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
mapping.add("evidence", evidence);
|
mapping.add("evidence", evidence);
|
||||||
if (!typedCases.cases.containsKey((long) recovered.caseValue)) {
|
if (!typedCases.cases.containsKey((long) recovered.tableIndex)) {
|
||||||
mappings.add(mapping);
|
mappings.add(mapping);
|
||||||
}
|
}
|
||||||
coveredTargets.add(recovered.targetAddress);
|
coveredTargets.add(recovered.targetAddress);
|
||||||
@@ -2213,7 +2265,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (recoveredAArch64 == null && loadTables.length == 0 && currentProgram.getLanguageID().getIdAsString().startsWith("AARCH64:")) {
|
if (recoveredAArch64 == null && loadTables.length == 0 && currentProgram.getLanguageID().getIdAsString().startsWith("AARCH64:")) {
|
||||||
limitations.add("REA did not verify a supported byte/halfword relative dispatch chain with exact unsigned bounds and register definitions; unsupported encodings and ambiguous bounds remain unknown.");
|
limitations.add("REA did not verify a supported signed or unsigned byte/halfword relative dispatch chain with exact unsigned bounds and register definitions; unsupported encodings and ambiguous bounds remain unknown.");
|
||||||
}
|
}
|
||||||
if (targets.length == 0) {
|
if (targets.length == 0) {
|
||||||
limitations.add(
|
limitations.add(
|
||||||
@@ -2343,12 +2395,21 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static final class RecoveredAArch64Mapping {
|
private static final class RecoveredAArch64Mapping {
|
||||||
private final int caseValue;
|
private final int tableIndex;
|
||||||
private final Address targetAddress;
|
private final Address targetAddress;
|
||||||
|
private final long encodedEntry;
|
||||||
|
private final boolean nullEntry;
|
||||||
|
|
||||||
private RecoveredAArch64Mapping(int caseValue, Address targetAddress) {
|
private RecoveredAArch64Mapping(
|
||||||
this.caseValue = caseValue;
|
int tableIndex,
|
||||||
|
Address targetAddress,
|
||||||
|
long encodedEntry,
|
||||||
|
boolean nullEntry
|
||||||
|
) {
|
||||||
|
this.tableIndex = tableIndex;
|
||||||
this.targetAddress = targetAddress;
|
this.targetAddress = targetAddress;
|
||||||
|
this.encodedEntry = encodedEntry;
|
||||||
|
this.nullEntry = nullEntry;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2356,24 +2417,70 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
|||||||
private final Address tableAddress;
|
private final Address tableAddress;
|
||||||
private final Address branchBase;
|
private final Address branchBase;
|
||||||
private final int entrySize;
|
private final int entrySize;
|
||||||
|
private final Address priorSelectorDefinition;
|
||||||
|
private final boolean signedEntries;
|
||||||
|
private final int offsetShift;
|
||||||
|
private final String guardMnemonic;
|
||||||
|
private final long compareImmediate;
|
||||||
|
private final Address nullTarget;
|
||||||
|
private final Address unreachableZeroTarget;
|
||||||
private final List<RecoveredAArch64Mapping> mappings;
|
private final List<RecoveredAArch64Mapping> mappings;
|
||||||
|
|
||||||
private RecoveredAArch64RelativeTable(
|
private RecoveredAArch64RelativeTable(
|
||||||
Address tableAddress,
|
Address tableAddress,
|
||||||
Address branchBase,
|
Address branchBase,
|
||||||
int entrySize,
|
int entrySize,
|
||||||
|
Address priorSelectorDefinition,
|
||||||
|
boolean signedEntries,
|
||||||
|
int offsetShift,
|
||||||
|
String guardMnemonic,
|
||||||
|
long compareImmediate,
|
||||||
|
Address nullTarget,
|
||||||
|
Address unreachableZeroTarget,
|
||||||
List<RecoveredAArch64Mapping> mappings
|
List<RecoveredAArch64Mapping> mappings
|
||||||
) {
|
) {
|
||||||
this.tableAddress = tableAddress;
|
this.tableAddress = tableAddress;
|
||||||
this.branchBase = branchBase;
|
this.branchBase = branchBase;
|
||||||
this.entrySize = entrySize;
|
this.entrySize = entrySize;
|
||||||
|
this.priorSelectorDefinition = priorSelectorDefinition;
|
||||||
|
this.signedEntries = signedEntries;
|
||||||
|
this.offsetShift = offsetShift;
|
||||||
|
this.guardMnemonic = guardMnemonic;
|
||||||
|
this.compareImmediate = compareImmediate;
|
||||||
|
this.nullTarget = nullTarget;
|
||||||
|
this.unreachableZeroTarget = unreachableZeroTarget;
|
||||||
this.mappings = mappings;
|
this.mappings = mappings;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static final Pattern AARCH64_DISPATCH_ADD = Pattern.compile(
|
||||||
|
"(?i)^([wx]\\d+),\\s*([wx]\\d+),\\s*([wx]\\d+)(?:,\\s*LSL\\s*#(?:0x)?([0-9a-f]+))?$"
|
||||||
|
);
|
||||||
|
private static final Pattern AARCH64_TABLE_LOAD = Pattern.compile(
|
||||||
|
"(?i)^([wx]\\d+),\\s*\\[([wx]\\d+),\\s*([wx]\\d+)(?:,\\s*(LSL|UXTW)\\s*(?:#(?:0x)?([0-9a-f]+))?)?\\s*\\]$"
|
||||||
|
);
|
||||||
|
private static final Pattern AARCH64_REGISTER_AND_ADDRESS = Pattern.compile(
|
||||||
|
"(?i)^([wx]\\d+),\\s*(0x[0-9a-f]+)$"
|
||||||
|
);
|
||||||
|
private static final Pattern AARCH64_ADD_IMMEDIATE = Pattern.compile(
|
||||||
|
"(?i)^([wx]\\d+),\\s*([wx]\\d+),\\s*#?(0x[0-9a-f]+|[0-9]+)$"
|
||||||
|
);
|
||||||
|
private static final Pattern AARCH64_COMPARE_IMMEDIATE = Pattern.compile(
|
||||||
|
"(?i)^([wx]\\d+),\\s*#?(0x[0-9a-f]+|[0-9]+)$"
|
||||||
|
);
|
||||||
|
private static final Pattern AARCH64_MOVE_REGISTER = Pattern.compile(
|
||||||
|
"(?i)^([wx]\\d+),\\s*([wx]\\d+)$"
|
||||||
|
);
|
||||||
|
private static final Set<String> AARCH64_FLAG_PRESERVING =
|
||||||
|
Set.of("MOV", "ADRP", "ADR", "ADD");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recover only the directly verified AArch64 byte/halfword-indexed relative branch
|
* Recover only directly verified AArch64 relative branch tables:
|
||||||
* table form. Other instruction sequences keep their case labels unknown.
|
* ADRP/ADD table base, LDRB/LDRH/LDRSB/LDRSH entry load with an exact
|
||||||
|
* index scale, ADR branch base, optional CBZ on the loaded entry, and
|
||||||
|
* ADD (byte or LSL #2 scale)/BR, with a selector bounded by CMP #imm and an
|
||||||
|
* unsigned B.HI or B.CS/B.HS guard on one straight-line path. Any other
|
||||||
|
* instruction sequence keeps its case labels unknown.
|
||||||
*/
|
*/
|
||||||
private RecoveredAArch64RelativeTable recoverAArch64RelativeTable(
|
private RecoveredAArch64RelativeTable recoverAArch64RelativeTable(
|
||||||
JumpTable jumpTable
|
JumpTable jumpTable
|
||||||
@@ -2395,167 +2502,265 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
|||||||
if (instruction.getAddress().equals(jumpTable.getSwitchAddress())) break;
|
if (instruction.getAddress().equals(jumpTable.getSwitchAddress())) break;
|
||||||
}
|
}
|
||||||
int branchIndex = instructions.size() - 1;
|
int branchIndex = instructions.size() - 1;
|
||||||
if (branchIndex < 1 ||
|
if (branchIndex < 1 || !mnemonic(instructions.get(branchIndex)).equals("BR")) {
|
||||||
!instructions.get(branchIndex).getMnemonicString().equalsIgnoreCase("BR")) {
|
return null;
|
||||||
|
}
|
||||||
|
Set<String> recoveredTargetSet = new HashSet<>();
|
||||||
|
for (Address target : jumpTable.getCases()) recoveredTargetSet.add(canonicalAddress(target));
|
||||||
|
if (recoveredTargetSet.isEmpty()) return null;
|
||||||
|
return matchAArch64RelativeDispatch(
|
||||||
|
instructions,
|
||||||
|
branchIndex,
|
||||||
|
recoveredTargetSet,
|
||||||
|
dispatchPathDataReferences(jumpTable)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private RecoveredAArch64RelativeTable matchAArch64RelativeDispatch(
|
||||||
|
List<Instruction> instructions,
|
||||||
|
int branchIndex,
|
||||||
|
Set<String> recoveredTargetSet,
|
||||||
|
List<Address> dataAddresses
|
||||||
|
) throws Exception {
|
||||||
|
String branchRegister = normalizedRegister(operands(instructions.get(branchIndex)));
|
||||||
|
|
||||||
|
// ADD Xd, Xn, Xm{, LSL #s}; BR Xd. LSL applies to Xm only.
|
||||||
|
Instruction dispatchAdd = instructions.get(branchIndex - 1);
|
||||||
|
if (!mnemonic(dispatchAdd).equals("ADD")) return null;
|
||||||
|
java.util.regex.Matcher addMatcher = AARCH64_DISPATCH_ADD.matcher(operands(dispatchAdd));
|
||||||
|
if (!addMatcher.matches() || !addMatcher.group(1).toLowerCase(Locale.ROOT).startsWith("x")) return null;
|
||||||
|
if (!normalizedRegister(addMatcher.group(1)).equals(branchRegister)) return null;
|
||||||
|
int offsetShift = addMatcher.group(4) == null ? 0 : Integer.parseInt(addMatcher.group(4), 16);
|
||||||
|
if (offsetShift != 0 && offsetShift != 2) return null;
|
||||||
|
String firstSource = normalizedRegister(addMatcher.group(2));
|
||||||
|
String secondSource = normalizedRegister(addMatcher.group(3));
|
||||||
|
if (firstSource.equals(secondSource)) return null;
|
||||||
|
|
||||||
|
// The entry register is the source whose latest definition is the
|
||||||
|
// table load; the other source must be defined by ADR.
|
||||||
|
int loadIndex = -1;
|
||||||
|
String entryRegister = null;
|
||||||
|
String baseRegister = null;
|
||||||
|
for (String candidate : offsetShift == 0 ? List.of(secondSource, firstSource) : List.of(secondSource)) {
|
||||||
|
int writer = latestWriter(instructions, branchIndex - 1, candidate);
|
||||||
|
if (writer >= 0 && Set.of("LDRB", "LDRH", "LDRSB", "LDRSH").contains(mnemonic(instructions.get(writer)))) {
|
||||||
|
loadIndex = writer;
|
||||||
|
entryRegister = candidate;
|
||||||
|
baseRegister = candidate.equals(firstSource) ? secondSource : firstSource;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (loadIndex < 0) return null;
|
||||||
|
|
||||||
|
Instruction load = instructions.get(loadIndex);
|
||||||
|
String loadMnemonic = mnemonic(load);
|
||||||
|
int entrySize = loadMnemonic.endsWith("B") ? 1 : 2;
|
||||||
|
boolean signedEntries = loadMnemonic.startsWith("LDRS");
|
||||||
|
java.util.regex.Matcher loadMatcher = AARCH64_TABLE_LOAD.matcher(operands(load));
|
||||||
|
if (!loadMatcher.matches()) return null;
|
||||||
|
// A signed load into a W register sign-extends only to 32 bits; the
|
||||||
|
// 64-bit dispatch ADD would then read a zero-extended value.
|
||||||
|
if (signedEntries && !loadMatcher.group(1).toLowerCase(Locale.ROOT).startsWith("x")) return null;
|
||||||
|
String tableBaseRegister = normalizedRegister(loadMatcher.group(2));
|
||||||
|
String indexRegister = normalizedRegister(loadMatcher.group(3));
|
||||||
|
boolean wordIndex = loadMatcher.group(3).toLowerCase(Locale.ROOT).startsWith("w");
|
||||||
|
String extend = loadMatcher.group(4) == null ? null : loadMatcher.group(4).toUpperCase(Locale.ROOT);
|
||||||
|
int indexShift = loadMatcher.group(5) == null ? 0 : Integer.parseInt(loadMatcher.group(5), 16);
|
||||||
|
if (wordIndex ? !"UXTW".equals(extend) : "UXTW".equals(extend)) return null;
|
||||||
|
if (indexShift != (entrySize == 1 ? 0 : 1)) return null;
|
||||||
|
|
||||||
|
// Only ADR and one CBZ on the entry may sit between load and ADD.
|
||||||
|
Address nullTarget = null;
|
||||||
|
for (int index = loadIndex + 1; index < branchIndex - 1; index += 1) {
|
||||||
|
Instruction instruction = instructions.get(index);
|
||||||
|
String name = mnemonic(instruction);
|
||||||
|
if (name.equals("ADR")) continue;
|
||||||
|
if (name.equals("CBZ") && nullTarget == null) {
|
||||||
|
String[] parts = operands(instruction).split(",");
|
||||||
|
Address[] flows = instruction.getFlows();
|
||||||
|
if (parts.length != 2 || flows.length != 1 ||
|
||||||
|
!normalizedRegister(parts[0]).equals(entryRegister)) return null;
|
||||||
|
nullTarget = flows[0];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Address[] recoveredTargets = jumpTable.getCases();
|
int baseIndex = latestWriter(instructions, branchIndex - 1, baseRegister);
|
||||||
Set<String> recoveredTargetSet = new HashSet<>();
|
if (baseIndex < 0 || !mnemonic(instructions.get(baseIndex)).equals("ADR")) return null;
|
||||||
for (Address target : recoveredTargets) recoveredTargetSet.add(canonicalAddress(target));
|
java.util.regex.Matcher baseMatcher = AARCH64_REGISTER_AND_ADDRESS.matcher(operands(instructions.get(baseIndex)));
|
||||||
List<Address> dataAddresses = dispatchPathDataReferences(jumpTable);
|
if (!baseMatcher.matches()) return null;
|
||||||
|
Address branchBase = addressFromOperand(baseMatcher.group(2));
|
||||||
|
|
||||||
for (int loadIndex = 0; loadIndex < branchIndex; loadIndex += 1) {
|
int tableAddIndex = latestWriter(instructions, loadIndex, tableBaseRegister);
|
||||||
Instruction load = instructions.get(loadIndex);
|
if (tableAddIndex < 0 || !mnemonic(instructions.get(tableAddIndex)).equals("ADD")) return null;
|
||||||
int entrySize = load.getMnemonicString().equalsIgnoreCase("LDRB") ? 1 : load.getMnemonicString().equalsIgnoreCase("LDRH") ? 2 : 0;
|
java.util.regex.Matcher tableAddMatcher = AARCH64_ADD_IMMEDIATE.matcher(operands(instructions.get(tableAddIndex)));
|
||||||
if (entrySize == 0 || branchIndex != loadIndex + 2 || loadIndex < 3 ||
|
if (!tableAddMatcher.matches() ||
|
||||||
!instructions.get(loadIndex - 1).getMnemonicString().equalsIgnoreCase("ADR") ||
|
!normalizedRegister(tableAddMatcher.group(1)).equals(tableBaseRegister) ||
|
||||||
!instructions.get(loadIndex - 2).getMnemonicString().equalsIgnoreCase("ADD") ||
|
!normalizedRegister(tableAddMatcher.group(2)).equals(tableBaseRegister)) return null;
|
||||||
!instructions.get(loadIndex - 3).getMnemonicString().equalsIgnoreCase("ADRP")) continue;
|
int tablePageIndex = latestWriter(instructions, tableAddIndex, tableBaseRegister);
|
||||||
String loadOperands = operands(load);
|
if (tablePageIndex < 0 || !mnemonic(instructions.get(tablePageIndex)).equals("ADRP")) return null;
|
||||||
java.util.regex.Matcher loadMatcher = java.util.regex.Pattern
|
java.util.regex.Matcher pageMatcher = AARCH64_REGISTER_AND_ADDRESS.matcher(operands(instructions.get(tablePageIndex)));
|
||||||
.compile(entrySize == 1 ? "(?i)^[wx]\\d+,\\s*\\[([wx]\\d+),\\s*([wx]\\d+)\\]$" : "(?i)^[wx]\\d+,\\s*\\[([wx]\\d+),\\s*([wx]\\d+),\\s*LSL\\s*#(?:0x)?1\\]$")
|
if (!pageMatcher.matches() || !normalizedRegister(pageMatcher.group(1)).equals(tableBaseRegister)) return null;
|
||||||
.matcher(loadOperands);
|
Address tableAddress = addressFromOperand(pageMatcher.group(2)).add(parseInteger(tableAddMatcher.group(3)));
|
||||||
if (!loadMatcher.matches()) continue;
|
if (!currentProgram.getMemory().contains(tableAddress) ||
|
||||||
String loadedRegister = normalizedRegister(loadOperands.substring(0, loadOperands.indexOf(',')));
|
(!dataAddresses.isEmpty() && !dataAddresses.contains(tableAddress))) return null;
|
||||||
String tableBaseRegister = normalizedRegister(loadMatcher.group(1));
|
|
||||||
String indexRegister = normalizedRegister(loadMatcher.group(2));
|
|
||||||
|
|
||||||
Address resolvedTableAddress = null;
|
// Unsigned guard: CMP Rs, #imm, then B.HI (imm + 1 entries) or
|
||||||
for (int index = loadIndex - 3; index < loadIndex - 1; index += 1) {
|
// B.CS/B.HS (imm entries), with only flag-preserving moves and
|
||||||
Instruction instruction = instructions.get(index);
|
// address arithmetic between the compare and the load.
|
||||||
String mnemonic = instruction.getMnemonicString().toUpperCase(Locale.ROOT);
|
int guardIndex = -1;
|
||||||
String text = operands(instruction);
|
for (int index = loadIndex - 1; index >= 0; index -= 1) {
|
||||||
if (mnemonic.equals("ADRP") &&
|
String name = mnemonic(instructions.get(index));
|
||||||
text.matches("(?i)^[wx]\\d+,\\s*0x[0-9a-f]+$")) {
|
if (name.startsWith("B.")) {
|
||||||
String[] parts = text.split(",");
|
guardIndex = index;
|
||||||
if (normalizedRegister(parts[0]).equals(tableBaseRegister)) {
|
break;
|
||||||
resolvedTableAddress = addressFromOperand(parts[1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (mnemonic.equals("ADD") && resolvedTableAddress != null &&
|
|
||||||
text.matches("(?i)^[wx]\\d+,\\s*[wx]\\d+,\\s*#?(?:0x[0-9a-f]+|[0-9]+)$")) {
|
|
||||||
String[] parts = text.split(",");
|
|
||||||
if (normalizedRegister(parts[0]).equals(tableBaseRegister) &&
|
|
||||||
normalizedRegister(parts[1]).equals(tableBaseRegister)) {
|
|
||||||
resolvedTableAddress = resolvedTableAddress.add(parseInteger(parts[2].trim().replaceFirst("^#", "")));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (resolvedTableAddress == null || !currentProgram.getMemory().contains(resolvedTableAddress) ||
|
if (!AARCH64_FLAG_PRESERVING.contains(name)) return null;
|
||||||
(!dataAddresses.isEmpty() && !dataAddresses.contains(resolvedTableAddress))) continue;
|
|
||||||
Address tableAddress = resolvedTableAddress;
|
|
||||||
|
|
||||||
Address branchBase = null;
|
|
||||||
boolean addressChain = false;
|
|
||||||
for (int index = loadIndex - 1; index < branchIndex; index += 1) {
|
|
||||||
Instruction instruction = instructions.get(index);
|
|
||||||
String mnemonic = instruction.getMnemonicString().toUpperCase(Locale.ROOT);
|
|
||||||
String text = operands(instruction);
|
|
||||||
if (index < loadIndex && mnemonic.equals("ADR") && text.matches("(?i)^[wx]\\d+,\\s*0x[0-9a-f]+$")) {
|
|
||||||
int comma = text.indexOf(',');
|
|
||||||
String destination = normalizedRegister(text.substring(0, comma));
|
|
||||||
if (!destination.equals(normalizedRegister(operands(instructions.get(branchIndex))))) continue;
|
|
||||||
branchBase = addressFromOperand(text.substring(comma + 1));
|
|
||||||
}
|
|
||||||
if (index > loadIndex && mnemonic.equals("ADD") && branchBase != null &&
|
|
||||||
text.matches("(?i)^[wx]\\d+,\\s*[wx]\\d+,\\s*[wx]\\d+,\\s*LSL\\s*#(?:0x)?2$")) {
|
|
||||||
String[] parts = text.split(",");
|
|
||||||
String destination = normalizedRegister(parts[0]);
|
|
||||||
String base = normalizedRegister(parts[1]);
|
|
||||||
String offset = normalizedRegister(parts[2]);
|
|
||||||
if (destination.equals(base) &&
|
|
||||||
base.equals(normalizedRegister(operands(instructions.get(branchIndex)))) &&
|
|
||||||
offset.equals(loadedRegister)) {
|
|
||||||
addressChain = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!addressChain || branchBase == null) continue;
|
|
||||||
|
|
||||||
int entryCount = -1;
|
|
||||||
Address defaultTarget = null;
|
|
||||||
for (int compareIndex = 0; compareIndex < loadIndex; compareIndex += 1) {
|
|
||||||
Instruction compare = instructions.get(compareIndex);
|
|
||||||
if (!compare.getMnemonicString().equalsIgnoreCase("CMP")) continue;
|
|
||||||
java.util.regex.Matcher compareMatcher = java.util.regex.Pattern
|
|
||||||
.compile("(?i)^([wx]\\d+),\\s*#?(0x[0-9a-f]+|[0-9]+)$")
|
|
||||||
.matcher(operands(compare));
|
|
||||||
if (!compareMatcher.matches()) continue;
|
|
||||||
String comparedRegister = normalizedRegister(compareMatcher.group(1));
|
|
||||||
long upperBound = parseInteger(compareMatcher.group(2));
|
|
||||||
if (upperBound < 0 || upperBound >= 4096) continue;
|
|
||||||
for (int guardIndex = compareIndex + 1; guardIndex < loadIndex; guardIndex += 1) {
|
|
||||||
Instruction guard = instructions.get(guardIndex);
|
|
||||||
String mnemonic = guard.getMnemonicString().toUpperCase(Locale.ROOT);
|
|
||||||
if (!mnemonic.equals("B.HI")) continue;
|
|
||||||
Address[] flows = guard.getFlows();
|
|
||||||
if (flows.length != 1 || !recoveredTargetSet.contains(canonicalAddress(flows[0]))) continue;
|
|
||||||
boolean supportedSequence = true;
|
|
||||||
for (int sequenceIndex = compareIndex + 1; sequenceIndex < loadIndex; sequenceIndex += 1) {
|
|
||||||
String sequenceMnemonic = instructions.get(sequenceIndex)
|
|
||||||
.getMnemonicString().toUpperCase(Locale.ROOT);
|
|
||||||
if (!Set.of("B.HI", "MOV", "ADRP", "ADD", "ADR").contains(sequenceMnemonic)) {
|
|
||||||
supportedSequence = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Set<String> selectorAliases = new HashSet<>();
|
|
||||||
selectorAliases.add(comparedRegister);
|
|
||||||
Set<String> boundedIndexes = new HashSet<>();
|
|
||||||
if (compareMatcher.group(1).toLowerCase(Locale.ROOT).startsWith("x")) boundedIndexes.add(comparedRegister);
|
|
||||||
for (int sequenceIndex = compareIndex + 1; sequenceIndex < loadIndex; sequenceIndex++) {
|
|
||||||
Instruction instruction = instructions.get(sequenceIndex);
|
|
||||||
String[] parts = operands(instruction).split(",");
|
|
||||||
if (parts.length == 0) continue;
|
|
||||||
if (instruction.getMnemonicString().equalsIgnoreCase("MOV")) {
|
|
||||||
String destination = normalizedRegister(parts[0]);
|
|
||||||
String source = parts.length == 2 ? normalizedRegister(parts[1]) : "";
|
|
||||||
boolean aliasesSelector = selectorAliases.contains(source);
|
|
||||||
boolean fullWidthBounded = boundedIndexes.contains(source) ||
|
|
||||||
(parts[0].trim().toLowerCase(Locale.ROOT).startsWith("w") && aliasesSelector);
|
|
||||||
if (aliasesSelector) selectorAliases.add(destination);
|
|
||||||
else selectorAliases.remove(destination);
|
|
||||||
if (fullWidthBounded) boundedIndexes.add(destination);
|
|
||||||
else boundedIndexes.remove(destination);
|
|
||||||
} else if (Set.of("ADRP", "ADD", "ADR").contains(instruction.getMnemonicString().toUpperCase(Locale.ROOT))) {
|
|
||||||
selectorAliases.remove(normalizedRegister(parts[0]));
|
|
||||||
boundedIndexes.remove(normalizedRegister(parts[0]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
boolean selectorFeedsTable = boundedIndexes.contains(indexRegister);
|
|
||||||
if (compareIndex < loadIndex - 3 && selectorFeedsTable && supportedSequence) {
|
|
||||||
entryCount = (int) upperBound + 1;
|
|
||||||
defaultTarget = flows[0];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (entryCount > 0) break;
|
|
||||||
}
|
|
||||||
if (entryCount <= 0 || entryCount > 4096 || defaultTarget == null) continue;
|
|
||||||
|
|
||||||
List<RecoveredAArch64Mapping> mappings = new ArrayList<>();
|
|
||||||
Set<String> mappedTargets = new HashSet<>();
|
|
||||||
boolean valid = true;
|
|
||||||
for (int caseValue = 0; caseValue < entryCount; caseValue += 1) {
|
|
||||||
Address entry = tableAddress.add((long) caseValue * entrySize);
|
|
||||||
int encodedOffset = entrySize == 1 ? currentProgram.getMemory().getByte(entry) & 0xff : currentProgram.getMemory().getShort(entry) & 0xffff;
|
|
||||||
Address target = branchBase.add((long) encodedOffset * 4L);
|
|
||||||
String canonicalTarget = canonicalAddress(target);
|
|
||||||
if (!recoveredTargetSet.contains(canonicalTarget)) {
|
|
||||||
valid = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
mappedTargets.add(canonicalTarget);
|
|
||||||
mappings.add(new RecoveredAArch64Mapping(caseValue, target));
|
|
||||||
}
|
|
||||||
if (!valid || mappedTargets.contains(canonicalAddress(defaultTarget))) continue;
|
|
||||||
Set<String> accountedTargets = new HashSet<>(mappedTargets);
|
|
||||||
accountedTargets.add(canonicalAddress(defaultTarget));
|
|
||||||
if (!accountedTargets.equals(recoveredTargetSet)) continue;
|
|
||||||
return new RecoveredAArch64RelativeTable(tableAddress, branchBase, entrySize, List.copyOf(mappings));
|
|
||||||
}
|
}
|
||||||
return null;
|
if (guardIndex < 1) return null;
|
||||||
|
Instruction guard = instructions.get(guardIndex);
|
||||||
|
String guardMnemonic = mnemonic(guard);
|
||||||
|
if (!Set.of("B.HI", "B.CS", "B.HS").contains(guardMnemonic)) return null;
|
||||||
|
Address[] guardFlows = guard.getFlows();
|
||||||
|
if (guardFlows.length != 1) return null;
|
||||||
|
Address defaultTarget = guardFlows[0];
|
||||||
|
int compareIndex = -1;
|
||||||
|
for (int index = guardIndex - 1; index >= 0; index -= 1) {
|
||||||
|
String name = mnemonic(instructions.get(index));
|
||||||
|
if (name.equals("CMP")) {
|
||||||
|
compareIndex = index;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!AARCH64_FLAG_PRESERVING.contains(name)) return null;
|
||||||
|
}
|
||||||
|
if (compareIndex < 0) return null;
|
||||||
|
java.util.regex.Matcher compareMatcher = AARCH64_COMPARE_IMMEDIATE.matcher(operands(instructions.get(compareIndex)));
|
||||||
|
if (!compareMatcher.matches()) return null;
|
||||||
|
String comparedRegister = normalizedRegister(compareMatcher.group(1));
|
||||||
|
boolean comparedFullWidth = compareMatcher.group(1).toLowerCase(Locale.ROOT).startsWith("x");
|
||||||
|
long compareImmediate = parseInteger(compareMatcher.group(2));
|
||||||
|
long entryCountLong = guardMnemonic.equals("B.HI") ? compareImmediate + 1 : compareImmediate;
|
||||||
|
if (entryCountLong <= 0 || entryCountLong > 4096) return null;
|
||||||
|
int entryCount = (int) entryCountLong;
|
||||||
|
|
||||||
|
// The loaded index must carry the compared value: either the compared
|
||||||
|
// register itself, unchanged since CMP, or a MOV copy made after CMP.
|
||||||
|
// A W compare bounds only the low 32 bits, so an X index needs a
|
||||||
|
// zero-extending W copy; a W index is zero-extended by UXTW.
|
||||||
|
int indexWriter = latestWriter(instructions, loadIndex, indexRegister);
|
||||||
|
boolean bounded = false;
|
||||||
|
if (indexWriter < compareIndex) {
|
||||||
|
bounded = indexRegister.equals(comparedRegister) && (wordIndex || comparedFullWidth);
|
||||||
|
}
|
||||||
|
else if (mnemonic(instructions.get(indexWriter)).equals("MOV")) {
|
||||||
|
java.util.regex.Matcher moveMatcher = AARCH64_MOVE_REGISTER.matcher(operands(instructions.get(indexWriter)));
|
||||||
|
if (moveMatcher.matches() &&
|
||||||
|
normalizedRegister(moveMatcher.group(2)).equals(comparedRegister) &&
|
||||||
|
latestWriter(instructions, indexWriter, comparedRegister) < compareIndex) {
|
||||||
|
boolean wordCopy = moveMatcher.group(1).toLowerCase(Locale.ROOT).startsWith("w");
|
||||||
|
bounded = wordCopy || comparedFullWidth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!bounded) return null;
|
||||||
|
|
||||||
|
// Every fact above holds only on one straight-line fall-through path
|
||||||
|
// with no other entry into the window.
|
||||||
|
int windowStart = Math.min(Math.min(tablePageIndex, baseIndex), compareIndex);
|
||||||
|
for (int index = windowStart + 1; index <= branchIndex; index += 1) {
|
||||||
|
monitor.checkCancelled();
|
||||||
|
Instruction previous = instructions.get(index - 1);
|
||||||
|
Instruction current = instructions.get(index);
|
||||||
|
Address fallThrough = previous.getFallThrough();
|
||||||
|
if (fallThrough == null || !fallThrough.equals(current.getAddress())) return null;
|
||||||
|
ReferenceIterator incoming = currentProgram.getReferenceManager().getReferencesTo(current.getAddress());
|
||||||
|
while (incoming.hasNext()) {
|
||||||
|
if (incoming.next().getReferenceType().isFlow()) return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<RecoveredAArch64Mapping> mappings = new ArrayList<>();
|
||||||
|
Set<String> mappedTargets = new HashSet<>();
|
||||||
|
for (int tableIndex = 0; tableIndex < entryCount; tableIndex += 1) {
|
||||||
|
monitor.checkCancelled();
|
||||||
|
Address entry = tableAddress.add((long) tableIndex * entrySize);
|
||||||
|
if (!currentProgram.getMemory().contains(entry)) return null;
|
||||||
|
long encoded;
|
||||||
|
if (entrySize == 1) {
|
||||||
|
byte value = currentProgram.getMemory().getByte(entry);
|
||||||
|
encoded = signedEntries ? value : value & 0xffL;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
short value = currentProgram.getMemory().getShort(entry);
|
||||||
|
encoded = signedEntries ? value : value & 0xffffL;
|
||||||
|
}
|
||||||
|
if (encoded == 0 && nullTarget != null) {
|
||||||
|
mappings.add(new RecoveredAArch64Mapping(tableIndex, nullTarget, encoded, true));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Address target;
|
||||||
|
try {
|
||||||
|
target = branchBase.addNoWrap(encoded << offsetShift);
|
||||||
|
}
|
||||||
|
catch (ghidra.program.model.address.AddressOverflowException overflow) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String canonicalTarget = canonicalAddress(target);
|
||||||
|
if (!recoveredTargetSet.contains(canonicalTarget)) return null;
|
||||||
|
mappedTargets.add(canonicalTarget);
|
||||||
|
mappings.add(new RecoveredAArch64Mapping(tableIndex, target, encoded, false));
|
||||||
|
}
|
||||||
|
if (mappedTargets.contains(canonicalAddress(defaultTarget))) return null;
|
||||||
|
// Ghidra's recovered destinations must be fully accounted for by the
|
||||||
|
// decoded entries, the guard default, and the CBZ null path.
|
||||||
|
Set<String> accountedTargets = new HashSet<>(mappedTargets);
|
||||||
|
accountedTargets.add(canonicalAddress(defaultTarget));
|
||||||
|
if (nullTarget != null) accountedTargets.add(canonicalAddress(nullTarget));
|
||||||
|
// Ghidra does not model the CBZ, so it may decode a zero entry as a
|
||||||
|
// branch to the base itself. Only a zero entry encodes offset 0, and
|
||||||
|
// the CBZ diverts every zero entry, so the BR never reaches the base.
|
||||||
|
Address unreachableZeroTarget = null;
|
||||||
|
boolean divertedZeroEntry = mappings.stream().anyMatch(mapping -> mapping.nullEntry);
|
||||||
|
if (divertedZeroEntry &&
|
||||||
|
recoveredTargetSet.contains(canonicalAddress(branchBase)) &&
|
||||||
|
!mappedTargets.contains(canonicalAddress(branchBase)) &&
|
||||||
|
!branchBase.equals(defaultTarget) &&
|
||||||
|
!branchBase.equals(nullTarget)) {
|
||||||
|
unreachableZeroTarget = branchBase;
|
||||||
|
accountedTargets.add(canonicalAddress(branchBase));
|
||||||
|
}
|
||||||
|
if (!accountedTargets.containsAll(recoveredTargetSet)) return null;
|
||||||
|
int selectorWriter = latestWriter(instructions, compareIndex, comparedRegister);
|
||||||
|
return new RecoveredAArch64RelativeTable(
|
||||||
|
tableAddress,
|
||||||
|
branchBase,
|
||||||
|
entrySize,
|
||||||
|
selectorWriter < 0 ? null : instructions.get(selectorWriter).getAddress(),
|
||||||
|
signedEntries,
|
||||||
|
offsetShift,
|
||||||
|
guardMnemonic,
|
||||||
|
compareImmediate,
|
||||||
|
nullTarget,
|
||||||
|
unreachableZeroTarget,
|
||||||
|
List.copyOf(mappings)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String mnemonic(Instruction instruction) {
|
||||||
|
return instruction.getMnemonicString().toUpperCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Index of the last instruction before {@code before} that writes {@code register}, or -1. */
|
||||||
|
private static int latestWriter(List<Instruction> instructions, int before, String register) {
|
||||||
|
for (int index = before - 1; index >= 0; index -= 1) {
|
||||||
|
for (Object result : instructions.get(index).getResultObjects()) {
|
||||||
|
if (result instanceof Register written &&
|
||||||
|
normalizedRegister(written.getBaseRegister().getName()).equals(register)) {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String operands(Instruction instruction) {
|
private static String operands(Instruction instruction) {
|
||||||
|
|||||||
+64
-35
@@ -1,16 +1,25 @@
|
|||||||
# Optional NativeAOT metadata recovery
|
# Optional NativeAOT metadata recovery
|
||||||
|
|
||||||
REA integrates [Washi1337/ghidra-nativeaot](https://github.com/Washi1337/ghidra-nativeaot)
|
REA has a built-in read-only snapshot parser for x64 PE targets with .NET 8 RTR
|
||||||
through an optional headless adapter. The original source is an opt-in submodule,
|
9.1 metadata. It parses the exact admitted Ghidra session snapshot and uses an
|
||||||
pinned to `effeb734fc570c32650f88b159608979dc7b423e`; its MIT license and existing
|
in-memory overlay for rehydrated metadata; it does not define Ghidra DataTypes,
|
||||||
source notices are retained. No upstream source or algorithms are rewritten.
|
labels, functions, or memory bytes. The hydration semantics are adapted from
|
||||||
|
[Washi1337/ghidra-nativeaot](https://github.com/Washi1337/ghidra-nativeaot) at
|
||||||
|
`effeb734fc570c32650f88b159608979dc7b423e` (MIT), cross-checked against
|
||||||
|
.NET's `MethodTable.h` at runtime commit
|
||||||
|
`a3b2d40328be0be3f8dd43f0e5cc925b8827b044` (MIT). The required notice ships in
|
||||||
|
[`licenses/nativeaot-parser-MIT.txt`](../licenses/nativeaot-parser-MIT.txt).
|
||||||
|
|
||||||
The initial tested layout is .NET runtime **8.0.22**, NativeAOT RTR **9.1**,
|
The optional headless Ghidra adapter remains available for database annotations
|
||||||
x86-64 ELF and native Windows PE **targets analyzed on Linux x64**, using
|
and broader recovery. It uses the same pinned upstream commit as an opt-in
|
||||||
Ghidra **12.1.4** and JDK **21**. The build accepts any Ghidra 12.1.x release and
|
submodule and is a separate, more capable workflow.
|
||||||
javac 21 or newer, and it compiles with `--release 21`. Real verification of
|
|
||||||
this layout remains the Ghidra 12.1.4 and JDK 21 pair. Other NativeAOT layouts,
|
The parser currently handles x64 PE32+ and RTR 9.1, including bounded
|
||||||
architectures, and hosts are unsupported.
|
DEHYDRATED_DATA processing, MethodTable relationships, virtual slots, and
|
||||||
|
frozen strings. Other RTR versions and PE architectures are reported as
|
||||||
|
unsupported or partial. The matching .NET 8.0.22 fixture is source-built and
|
||||||
|
the parser's Windows-host Ghidra workflow has not yet had a real Windows-host
|
||||||
|
run. This does not establish Windows Ghidra import or decompiler behavior.
|
||||||
Windows Ghidra P0 has no database mutation authority. A PE/CLI or ReadyToRun
|
Windows Ghidra P0 has no database mutation authority. A PE/CLI or ReadyToRun
|
||||||
assembly should use `inspect_managed_artifact`; it is not NativeAOT.
|
assembly should use `inspect_managed_artifact`; it is not NativeAOT.
|
||||||
|
|
||||||
@@ -43,42 +52,62 @@ arbitrary caller-supplied bytes. The bounded loader accepts regular JARs up to
|
|||||||
|
|
||||||
## Use existing tools
|
## Use existing tools
|
||||||
|
|
||||||
Open the executable with provider `ghidra`. `inspect_native_load_image` returns
|
Open the executable with provider `ghidra`. For an x64 PE, `inspect_native_load_image`
|
||||||
optional `observations.metadata_recovery` inline, including the detected header,
|
returns `observations.metadata_recovery` inline from the captured source snapshot,
|
||||||
layout, recovery status, type category paths/addresses, diagnostics, annotation
|
including the RTR header, MethodTable addresses, frozen strings, derived overlay
|
||||||
coverage and derived-memory SHA. The enclosing `unsupported` load-image status
|
identity, and examined/recovered counts. Its enclosing `unsupported` load-image
|
||||||
for PE/ELF concerns independent DOS loader verification; metadata recovery has
|
status means independent PE load-image verification was not performed; it does
|
||||||
its own status. It is not a failed import.
|
not mean the metadata parser failed or the executable failed to load.
|
||||||
|
|
||||||
Pass one returned type path or its typed address to `inspect_native_data_type`.
|
Pass a returned MethodTable address to `inspect_native_data_type`. A direct PE
|
||||||
Its optional `metadata_recovery` includes related/base type, implemented
|
query also derives the snapshot metadata from the authenticated Ghidra session
|
||||||
interfaces and virtual slots with targets and known procedure names. Follow
|
handshake, so it does not require a preceding load-image call. In a persistent
|
||||||
these with `analyze_function`, string searches and ordinary cross-references.
|
MCP session, load-image followed by type inspection reuses that same derivation.
|
||||||
For CLI use:
|
The database result remains `unavailable` when Ghidra has no DataType at that
|
||||||
|
address; its `metadata_recovery.source` is `read-only-derived-overlay` and
|
||||||
|
carries related type, interface, slot, and target observations. Generated
|
||||||
|
address identities do not claim original managed names. The parser does not
|
||||||
|
merge frozen literals into Ghidra's database string inventory. Follow slot
|
||||||
|
targets with `analyze_function` and ordinary cross-references.
|
||||||
|
|
||||||
|
The built-in CLI path uses a MethodTable address returned by the parser or
|
||||||
|
another observation:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
rea inspect-native-load-image ./NativeAotFixture --provider ghidra --json
|
rea inspect-native-load-image ./NativeAotFixture --provider ghidra --json
|
||||||
rea inspect-native-data-type ./NativeAotFixture --type /NativeAOT/MethodTables/Class_ADDRESS_MT --provider ghidra --json
|
rea inspect-native-data-type ./NativeAotFixture --address 0xMETHODTABLE_ADDRESS --provider ghidra --json
|
||||||
rea function ./NativeAotFixture 0xADDRESS --provider ghidra --json
|
rea function ./NativeAotFixture 0xADDRESS --provider ghidra --json
|
||||||
```
|
```
|
||||||
|
|
||||||
No new MCP workflow or approval flag is required. Configuration opts into the
|
The generated `/NativeAOT/...` category path belongs to the optional headless
|
||||||
adapter; without it ordinary Ghidra profiles/results remain unchanged.
|
adapter. Use `--type /NativeAOT/MethodTables/Class_ADDRESS_MT` only when that
|
||||||
|
adapter is configured and has created the corresponding Ghidra DataType.
|
||||||
|
Built-in PE analysis includes its parser contract and resource policy in the
|
||||||
|
Ghidra analysis profile; non-PE profiles do not include that contract.
|
||||||
|
|
||||||
## Evidence boundaries
|
## Evidence boundaries
|
||||||
|
|
||||||
Discovery prefers an imported RTR symbol, otherwise the upstream initialized,
|
The built-in parser uses an initialized non-executable PE signature scan and
|
||||||
non-executable-memory signature heuristic. REA rejects ambiguous candidates,
|
requires a unique RTR 9.1 header and exactly one DEHYDRATED_DATA row. Rehydrated
|
||||||
unknown layout versions, malformed directory rows, unsupported flags and
|
bytes live only in a parser-owned overlay, identified by virtual address, size,
|
||||||
unmapped ranges before recovery. It requires the verified dehydrated layout.
|
and SHA-256; the caller's source Buffer remains unchanged. Limits derive from
|
||||||
|
captured section extents and format field bounds. Separate resource policies
|
||||||
|
admit up to 128 MiB of captured PE bytes, 64 MiB-equivalent parser work, 64 MiB
|
||||||
|
of exact parser-owned overlay/index Buffers, and 4 MiB of serialized NativeAOT
|
||||||
|
report JSON. These are local parser limits, independent of MCP delivery; the
|
||||||
|
buffer counter does not claim to cap V8 heap or total process memory. Rows that
|
||||||
|
do not fit the report budget are omitted with explicit partial status and
|
||||||
|
coverage. Coverage counts describe the pointer scan, recovered MethodTables,
|
||||||
|
and frozen-string candidates examined, not every possible runtime type or
|
||||||
|
object. Cancellation is honored during snapshot I/O and checked immediately
|
||||||
|
before and after parsing; the synchronous parser call itself is bounded but
|
||||||
|
cannot be interrupted mid-call. Parser output never claims that a Ghidra
|
||||||
|
database type was defined.
|
||||||
|
|
||||||
The adapter performs rehydration, method-table recovery and frozen-object
|
The optional adapter performs its annotations in an ephemeral Program
|
||||||
annotation in one ephemeral Program transaction. Fatal failure/cancellation
|
transaction. That workflow may change analysis-memory bytes, never the original
|
||||||
rolls it back. Recovery may change analysis-memory bytes, never the original
|
executable file. It is unavailable on Windows P0, which has no database
|
||||||
executable file. Derived ranges have a digest and **no original file offset**;
|
mutation authority.
|
||||||
they are not captured runtime state. Candidate/committed instance annotation
|
|
||||||
counts distinguish partial frozen-object coverage. Coverage concerns the
|
|
||||||
recovered candidate set, not every possible object or every runtime type.
|
|
||||||
|
|
||||||
REA preserves pre-recovery calling conventions and uses the loaded compiler
|
REA preserves pre-recovery calling conventions and uses the loaded compiler
|
||||||
specification default for newly created methods. The upstream universal
|
specification default for newly created methods. The upstream universal
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ Executable bytes remain unchanged.
|
|||||||
Use [installation](installation.md#ghidra) for supported hosts, prerequisites,
|
Use [installation](installation.md#ghidra) for supported hosts, prerequisites,
|
||||||
configuration, startup deadlines, resource controls, and cleanup recovery.
|
configuration, startup deadlines, resource controls, and cleanup recovery.
|
||||||
[Windows P0](windows-ghidra-p0.md) has its own admission and lifecycle boundary;
|
[Windows P0](windows-ghidra-p0.md) has its own admission and lifecycle boundary;
|
||||||
[NativeAOT recovery](ghidra-nativeaot.md) is an optional, separately verified
|
[NativeAOT observations](ghidra-nativeaot.md) include a built-in read-only PE
|
||||||
extension. Check the [release boundary](installation.md#released-package-and-main)
|
snapshot parser and a separate optional annotation extension. Check the
|
||||||
when using npm.
|
[release boundary](installation.md#released-package-and-main) when using npm.
|
||||||
|
|
||||||
See [import language and analysis seeds](installation.md#ghidra-import-language-and-analysis-seeds)
|
See [import language and analysis seeds](installation.md#ghidra-import-language-and-analysis-seeds)
|
||||||
to override the header-detected language or seed analysis.
|
to override the header-detected language or seed analysis.
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ manifest and graph commitments, JavaScript Application Graph, static
|
|||||||
Electron summary, reconstruction statistics, and explicit limitations. It does
|
Electron summary, reconstruction statistics, and explicit limitations. It does
|
||||||
not require a live Hopper, Ghidra, browser, or Electron process.
|
not require a live Hopper, Ghidra, browser, or Electron process.
|
||||||
|
|
||||||
|
The application graph selects the shallowest `package.json` outside
|
||||||
|
`node_modules` as its package root, breaking equal-depth ties by Unicode
|
||||||
|
code-point order. Dependency manifests remain package observations but do not
|
||||||
|
declare Electron main or renderer entries. If only dependency manifests are
|
||||||
|
present, the inventory artifact remains the graph root.
|
||||||
|
|
||||||
## ASAR integrity
|
## ASAR integrity
|
||||||
|
|
||||||
ASAR inventory checks Electron integrity metadata for embedded archive entries
|
ASAR inventory checks Electron integrity metadata for embedded archive entries
|
||||||
@@ -247,6 +253,46 @@ while locally shadowed receivers can still contribute network endpoint
|
|||||||
candidates. A template recovered with a parser error and no cooked value stays
|
candidates. A template recovered with a parser error and no cooked value stays
|
||||||
dynamic; its raw spelling is not treated as a valid JavaScript string.
|
dynamic; its raw spelling is not treated as a valid JavaScript string.
|
||||||
|
|
||||||
|
RPC client syntax also contributes network endpoint candidates, in both
|
||||||
|
`analyze_javascript_application` and `analyze_web_bundle`, distinguished by
|
||||||
|
`mechanism`:
|
||||||
|
|
||||||
|
- `trpc:procedure:<method>` records the static member path between the root
|
||||||
|
expression and the method, such as `invoice.list` in
|
||||||
|
`root.invoice.list.useQuery(...)`. tRPC proxies keep these property names
|
||||||
|
through identifier minification. React hooks and TanStack option factories
|
||||||
|
(`useQuery`, `useMutation`, `useInfiniteQuery`, `useSubscription`, suspense
|
||||||
|
and prefetch variants, `queryOptions`, `infiniteQueryOptions`,
|
||||||
|
`mutationOptions`, `subscriptionOptions`) are recognized directly. The
|
||||||
|
vanilla `query` and `mutate` methods are recognized on a root bound to
|
||||||
|
`createTRPCClient` or `createTRPCProxyClient` in the same lexical binding, or on a
|
||||||
|
path of at least two segments in a source containing `TRPCClientError`,
|
||||||
|
`trpc-accept`, or `/trpc`. Factory bindings must have one unconditional
|
||||||
|
initializer and no binding assignments; shadowed locals do not inherit client
|
||||||
|
identity. `subscribe` additionally requires a tRPC observer
|
||||||
|
argument (`onData`, `onError`, `onStarted`, `onStopped`, `onComplete`, or
|
||||||
|
`onConnectionStateChange`). `this` roots, browser globals, and any path
|
||||||
|
containing an RTK Query `endpoints` segment are never procedures; cache-key
|
||||||
|
helpers such as `queryKey` issue no request and are excluded.
|
||||||
|
- `trpc:link:<link>` records the literal `url` option of `httpLink`,
|
||||||
|
`httpBatchLink`, `httpBatchStreamLink`, and `httpSubscriptionLink` when the
|
||||||
|
link's callee name survives bundling, including `(0, module.httpBatchLink)`
|
||||||
|
import-call wrappers.
|
||||||
|
- `graphql:query`, `graphql:mutation`, and `graphql:subscription` record a
|
||||||
|
named operation found at the start of a string or cooked template literal —
|
||||||
|
the name must be followed by variable definitions (`($`), a directive, or a
|
||||||
|
selection set starting with a field name — or in a precompiled
|
||||||
|
`OperationDefinition` document object. A graphql-tag `loc.source.body` copy is
|
||||||
|
not reported twice. Anonymous operations are not reported.
|
||||||
|
|
||||||
|
A procedure path is the server-side procedure name, not a resolved URL; the
|
||||||
|
transport URL depends on the link configuration and batching. When the proxy is
|
||||||
|
reached through a member expression, such as a webpack module binding
|
||||||
|
(`l.S.invoice.list`) or a property holding the proxy (`o.api.post.byId`), the
|
||||||
|
recorded path keeps those leading carrier segments; the procedure path is its
|
||||||
|
suffix. Utility calls such as `useUtils().invoice.list.fetch()`, `useQueries`,
|
||||||
|
and links whose callee was renamed by a minifier are not recognized.
|
||||||
|
|
||||||
Each recovered bundle module retains the exact factory-source digest. A complete
|
Each recovered bundle module retains the exact factory-source digest. A complete
|
||||||
bounded AST also receives a `babel-ast-v1` structural fingerprint that ignores
|
bounded AST also receives a `babel-ast-v1` structural fingerprint that ignores
|
||||||
ordinary identifier names while retaining syntax, literals, operators, object
|
ordinary identifier names while retaining syntax, literals, operators, object
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# JavaScript source recovery
|
# JavaScript source recovery
|
||||||
|
|
||||||
`recover_javascript_sources` / `recover-javascript-sources` derives readable
|
`recover_javascript_sources` / `recover-javascript-sources` derives readable
|
||||||
modules from one explicitly selected UTF-8 JavaScript file or bundle. Use it on
|
modules from explicitly selected UTF-8 JavaScript files or bundles. Use it on
|
||||||
files returned by `export_web_scripts` or other local scripts. The operation
|
files returned by `export_web_scripts` or other local scripts. The operation
|
||||||
never executes the selected or recovered application code.
|
never executes the selected or recovered application code.
|
||||||
|
|
||||||
@@ -52,35 +52,58 @@ Both give the same binary (SHA-256
|
|||||||
```sh
|
```sh
|
||||||
rea recover-javascript-sources /tmp/web-scripts/app.js /tmp/recovered-app --json
|
rea recover-javascript-sources /tmp/web-scripts/app.js /tmp/recovered-app --json
|
||||||
rea analyze-javascript-application /tmp/recovered-app/modules --json
|
rea analyze-javascript-application /tmp/recovered-app/modules --json
|
||||||
|
|
||||||
|
# Recover related code-split chunks together; repeat --additional-path.
|
||||||
|
rea recover-javascript-sources /tmp/web-scripts/main.js /tmp/recovered-chunks \
|
||||||
|
--additional-path /tmp/web-scripts/runtime.js \
|
||||||
|
--additional-path /tmp/web-scripts/shared.js \
|
||||||
|
--additional-path /tmp/web-scripts/checkout.chunk.js --json
|
||||||
```
|
```
|
||||||
|
|
||||||
MCP input:
|
MCP input:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"path": "/tmp/web-scripts/app.js",
|
"path": "/tmp/web-scripts/main.js",
|
||||||
"output_directory": "/tmp/recovered-app"
|
"additional_paths": [
|
||||||
|
"/tmp/web-scripts/runtime.js",
|
||||||
|
"/tmp/web-scripts/shared.js",
|
||||||
|
"/tmp/web-scripts/checkout.chunk.js"
|
||||||
|
],
|
||||||
|
"output_directory": "/tmp/recovered-chunks"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The output directory must be absent, absolute, and have an existing parent.
|
The output directory must be absent, absolute, and have an existing parent.
|
||||||
The source is snapshotted in a private workspace, and the engine writes only to
|
Each source is snapshotted separately in a private workspace. Multiple inputs
|
||||||
|
retain their relative directory layout and are passed together to one Wakaru
|
||||||
|
invocation, allowing cross-chunk module IDs to resolve. Inputs must be distinct;
|
||||||
|
`additional_paths` accepts up to 16 additional files. The engine writes only to
|
||||||
its own staging directory. Reported module paths, input identities, UTF-8 byte
|
its own staging directory. Reported module paths, input identities, UTF-8 byte
|
||||||
ranges, regular files and emitted map representations are validated before
|
ranges, regular files and emitted map representations are validated before
|
||||||
publication. REA writes verified bytes to an exclusively owned output tree and
|
publication. REA writes verified bytes to an exclusively owned output tree and
|
||||||
rolls it back on failure. It also checks that the original source and engine
|
rolls it back on failure. It also checks that the original source and engine
|
||||||
bytes remain unchanged before committing. The existing output is never overwritten.
|
bytes remain unchanged before committing. The existing output is never overwritten.
|
||||||
|
|
||||||
|
Multi-input relative filenames must be representable unchanged as artifact paths:
|
||||||
|
NFC Unicode spelling and no literal backslashes. Unsupported layouts fail before
|
||||||
|
invoking the engine rather than silently renaming inputs referenced by source maps.
|
||||||
|
|
||||||
Published contents:
|
Published contents:
|
||||||
|
|
||||||
- `inputs/bundle.js`: exact original snapshot; its digest binds the Evidence.
|
- `inputs/bundle.js`: exact original snapshot for a single input. Multiple inputs
|
||||||
|
retain paths relative to their common parent under `inputs/`.
|
||||||
- `modules/`: derived modules and any upstream-emitted source maps.
|
- `modules/`: derived modules and any upstream-emitted source maps.
|
||||||
- `reports/stdout.json` and `reports/provenance.json`: exact producer reports.
|
- `reports/stdout.json` and `reports/provenance.json`: exact producer reports.
|
||||||
- `manifest.json`: identities, modules, transform choices, warnings and limitations.
|
- `manifest.json`: identities, modules, transform choices, warnings and limitations.
|
||||||
|
|
||||||
The inline result includes complete file paths/digests/sizes, extraction ranges,
|
The inline result includes complete file paths/digests/sizes, extraction ranges,
|
||||||
warnings and `analysis_input`, directly usable as the arguments to
|
warnings and `analysis_input`, directly usable as the arguments to
|
||||||
`analyze_javascript_application`. Raw Evidence retains selected command arguments,
|
`analyze_javascript_application`. `source` identifies the primary input and binds
|
||||||
|
the Evidence subject; `additional_sources` records the other snapshots and their
|
||||||
|
published copies when selected. Each module names its owning snapshot, original
|
||||||
|
path and original digest; all extraction and context ranges refer to that input.
|
||||||
|
Raw Evidence retains selected command arguments,
|
||||||
reported input paths, exit status, stderr and reports; temporary paths are recorded
|
reported input paths, exit status, stderr and reports; temporary paths are recorded
|
||||||
as historical execution coordinates after their workspace is removed.
|
as historical execution coordinates after their workspace is removed.
|
||||||
|
|
||||||
@@ -107,7 +130,8 @@ equivalence remain unknown. No network fetching occurs.
|
|||||||
## Resource policy and verification
|
## Resource policy and verification
|
||||||
|
|
||||||
Each operation uses one worker, a 1 GiB address-space ceiling and a 120-second
|
Each operation uses one worker, a 1 GiB address-space ceiling and a 120-second
|
||||||
deadline; it accepts up to 64 MiB of input and 128 MiB/10000 entries of staged
|
deadline; it accepts up to 64 MiB of total input across all selected files and
|
||||||
|
128 MiB/10000 entries of staged
|
||||||
output, with 8 MiB per diagnostic/report stream. Exceeding a limit is an explicit
|
output, with 8 MiB per diagnostic/report stream. Exceeding a limit is an explicit
|
||||||
failure with rollback. These limits apply per operation and do not claim aggregate
|
failure with rollback. These limits apply per operation and do not claim aggregate
|
||||||
host containment across concurrent callers.
|
host containment across concurrent callers.
|
||||||
|
|||||||
+20
-10
@@ -450,15 +450,15 @@ installation (`application.java.min` through `application.java.max`). Current
|
|||||||
12.1 releases require JDK 21 or newer and set no maximum. The lanes below still
|
12.1 releases require JDK 21 or newer and set no maximum. The lanes below still
|
||||||
prove behavior on the verified Ghidra 12.1.4 and JDK 21 build.
|
prove behavior on the verified Ghidra 12.1.4 and JDK 21 build.
|
||||||
|
|
||||||
| Ghidra lane | Supported runner/target | Additional local tools |
|
| Ghidra lane | Supported runner/target | Additional local tools |
|
||||||
| ------------------------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
|
| ------------------------------------------ | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
|
||||||
| `npm run verify:ghidra` | Linux x64/arm64 ELF or macOS x64/arm64 Mach-O | Host C compiler, Ghidra 12.1.4, and full JDK 21 |
|
| `npm run verify:ghidra` | Linux x64/arm64 ELF or macOS x64/arm64 Mach-O | Host C compiler, Ghidra 12.1.4, and full JDK 21 |
|
||||||
| `npm run verify:ghidra:seeds` | POSIX host; source-owned 32-bit x86 raw fixture; function order and BSS labels | Ghidra 12.1.4 and full JDK 21 |
|
| `npm run verify:ghidra:seeds` | POSIX host; source-owned 32-bit x86 raw fixture; function order and BSS labels | Ghidra 12.1.4 and full JDK 21 |
|
||||||
| `npm run verify:ghidra:swift` | macOS x64/arm64 Mach-O | Host Swift compiler, Ghidra 12.1.4, and full JDK 21 |
|
| `npm run verify:ghidra:swift` | macOS x64/arm64 Mach-O | Host Swift compiler, Ghidra 12.1.4, and full JDK 21 |
|
||||||
| `npm run verify:ghidra:switch` | Linux x64 ELF; GCC/Clang optimized and stripped switch fixtures | GCC, Clang, GNU nm/objdump/strip, Ghidra 12.1.4, and full JDK 21 |
|
| `npm run verify:ghidra:switch` | Linux x64 ELF; GCC/Clang optimized and stripped switch fixtures | GCC, Clang, GNU nm/objdump/strip, Ghidra 12.1.4, and full JDK 21 |
|
||||||
| `npm run verify:ghidra:aarch64-jump-table` | Any supported Ghidra host; AArch64 ELF; byte/halfword tables; host ARM64 Mach-O | Clang with AArch64 target support, Ghidra 12.1.4, and full JDK 21 |
|
| `npm run verify:ghidra:aarch64-jump-table` | Any supported Ghidra host; AArch64 ELF; signed/unsigned byte/halfword tables; host ARM64 Mach-O | Clang with AArch64 target support, Ghidra 12.1.4, and full JDK 21 |
|
||||||
| `npm run verify:ghidra:cross-format` | Any supported Ghidra host; also analyzes AArch64 ELF, x86-64 PE, and x86-64 Mach-O | `clang`, LLD, and `lld-link` in addition to host-lane prerequisites |
|
| `npm run verify:ghidra:cross-format` | Any supported Ghidra host; also analyzes AArch64 ELF, x86-64 PE, and x86-64 Mach-O | `clang`, LLD, and `lld-link` in addition to host-lane prerequisites |
|
||||||
| `npm run verify:ghidra:windows` | Controlled Windows x64 with native x86-64 PE; `-- --x86` selects native x86 PE | Ghidra 12.1.4, full JDK 21, and the matching native artifact |
|
| `npm run verify:ghidra:windows` | Controlled Windows x64 with native x86-64 PE; `-- --x86` selects native x86 PE | Ghidra 12.1.4, full JDK 21, and the matching native artifact |
|
||||||
|
|
||||||
The optional Swift lane compiles a source-owned fixture and checks modern and
|
The optional Swift lane compiles a source-owned fixture and checks modern and
|
||||||
provider-demangled procedure names through real stdio MCP, with and without the
|
provider-demangled procedure names through real stdio MCP, with and without the
|
||||||
@@ -672,6 +672,13 @@ boundaries and budgets.
|
|||||||
|
|
||||||
## Optional NativeAOT Ghidra analysis
|
## Optional NativeAOT Ghidra analysis
|
||||||
|
|
||||||
|
The source parser lane is independent of the optional annotation JAR. Its
|
||||||
|
synthetic RTR 9.1 PE golden checks captured-byte digest admission, rehydration
|
||||||
|
into an immutable parser overlay, MethodTable/slot relationships, and frozen
|
||||||
|
literal extraction; provider tests exercise `inspect_native_load_image` and
|
||||||
|
address-based `inspect_native_data_type` with the database result still
|
||||||
|
unavailable. These tests do not establish Windows Ghidra host behavior.
|
||||||
|
|
||||||
This lane is separate from the default native lane. `build:fixtures:nativeaot`
|
This lane is separate from the default native lane. `build:fixtures:nativeaot`
|
||||||
requires an existing .NET SDK 8.0.416, the platform NativeAOT compiler/linker and
|
requires an existing .NET SDK 8.0.416, the platform NativeAOT compiler/linker and
|
||||||
runtime pack 8.0.22. It builds benign sources into ignored `_reference/` and
|
runtime pack 8.0.22. It builds benign sources into ignored `_reference/` and
|
||||||
@@ -1029,7 +1036,10 @@ and `REA_JAVASCRIPT_FIXTURE_TOOLS` pointing to an isolated npm prefix containing
|
|||||||
esbuild 0.25.10 and webpack 5.101.3. No global installation is required.
|
esbuild 0.25.10 and webpack 5.101.3. No global installation is required.
|
||||||
The lane compiles source-owned fixtures, exercises CLI and stdio MCP, verifies
|
The lane compiles source-owned fixtures, exercises CLI and stdio MCP, verifies
|
||||||
published bytes and provenance, feeds recovered modules into existing analysis,
|
published bytes and provenance, feeds recovered modules into existing analysis,
|
||||||
and compares a finite set of known fixture results. The provider also accepts
|
and compares a finite set of known fixture results. A webpack fixture with separate
|
||||||
|
runtime, shared, entry and lazy chunks verifies multi-input recovery through both
|
||||||
|
surfaces, per-file provenance and resolved cross-chunk imports in the downstream
|
||||||
|
application graph. The provider also accepts
|
||||||
other ^1.13.0 releases and reports them as unverified; this lane proves the
|
other ^1.13.0 releases and reports them as unverified; this lane proves the
|
||||||
audited release. It does not establish
|
audited release. It does not establish
|
||||||
arbitrary recovered-application equivalence. CI installs these prerequisites only
|
arbitrary recovered-application equivalence. CI installs these prerequisites only
|
||||||
|
|||||||
@@ -21,9 +21,12 @@ additional permission flag or degraded mode is required.
|
|||||||
- the 25 read-only Ghidra inventory, memory, and function-analysis operations.
|
- the 25 read-only Ghidra inventory, memory, and function-analysis operations.
|
||||||
|
|
||||||
Loaded memory reads and file offsets preserve Ghidra's observed source mappings.
|
Loaded memory reads and file offsets preserve Ghidra's observed source mappings.
|
||||||
PE load-image inspection returns measurements with an explicit unsupported
|
PE load-image inspection returns an explicit unsupported attestation status;
|
||||||
attestation status; independent format-specific verification currently supports
|
independent load-image verification currently supports DOS MZ only. For x64 PE
|
||||||
DOS MZ only.
|
NativeAOT RTR 9.1, the read-only snapshot parser can report metadata through
|
||||||
|
`inspect_native_load_image` and `inspect_native_data_type` without a JAR or
|
||||||
|
database mutation. A real Windows-host NativeAOT Ghidra run is still required
|
||||||
|
to establish the complete host workflow.
|
||||||
|
|
||||||
This boundary does not establish general Windows feature parity. Ghidra GUI
|
This boundary does not establish general Windows feature parity. Ghidra GUI
|
||||||
state, annotations, other target architectures and formats, and Hopper-only
|
state, annotations, other target architectures and formats, and Hopper-only
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
=====================
|
||||||
|
|
||||||
|
Copyright © 2023-2025 Washi
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the “Software”), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
|
The read-only NativeAOT parser adapts format and hydration semantics from
|
||||||
|
Washi1337/ghidra-nativeaot commit
|
||||||
|
effeb734fc570c32650f88b159608979dc7b423e (MIT). MethodTable field layout was
|
||||||
|
cross-checked against .NET runtime src/coreclr/nativeaot/Runtime/inc/MethodTable.h
|
||||||
|
at dotnet/runtime commit a3b2d40328be0be3f8dd43f0e5cc925b8827b044 (MIT).
|
||||||
+2
-1
@@ -81,7 +81,8 @@
|
|||||||
"bridge/pwndbg/launch_impl.py",
|
"bridge/pwndbg/launch_impl.py",
|
||||||
"bridge/pwndbg/core_context.py",
|
"bridge/pwndbg/core_context.py",
|
||||||
"third_party/pwndbg/README.md",
|
"third_party/pwndbg/README.md",
|
||||||
"third_party/pwndbg/LICENSE.md"
|
"third_party/pwndbg/LICENSE.md",
|
||||||
|
"licenses/nativeaot-parser-MIT.txt"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"release:notes": "node scripts/release-notes.mjs",
|
"release:notes": "node scripts/release-notes.mjs",
|
||||||
|
|||||||
@@ -14,6 +14,24 @@ const flags = [
|
|||||||
"workflows",
|
"workflows",
|
||||||
...Object.keys(SCOPES),
|
...Object.keys(SCOPES),
|
||||||
];
|
];
|
||||||
|
// Actionlint validates workflow syntax only. The semantic suite asserts these
|
||||||
|
// workflows' release, publication and self-hosted runner safeguards.
|
||||||
|
const GUARDED_WORKFLOWS = new Set([
|
||||||
|
".github/workflows/release.yml",
|
||||||
|
".github/workflows/real-ghidra-windows.yml",
|
||||||
|
]);
|
||||||
|
// Sources compiled by build-conformance-fixtures.mjs. The readiness journey
|
||||||
|
// analyzes the built C fixture as well as its own applications.
|
||||||
|
const FIXTURE_SOURCES = [
|
||||||
|
"tests/conformance/c/**",
|
||||||
|
"tests/conformance/napi/**",
|
||||||
|
"tests/conformance/objc/**",
|
||||||
|
"tests/conformance/swift/**",
|
||||||
|
"tests/conformance/versions/**",
|
||||||
|
];
|
||||||
|
const READINESS_SOURCES = ["tests/conformance/readiness/**"];
|
||||||
|
const matchesAny = (file, patterns) =>
|
||||||
|
patterns.some((pattern) => matchesGlob(file, pattern));
|
||||||
const selected = Object.fromEntries(flags.map((flag) => [flag, false]));
|
const selected = Object.fromEntries(flags.map((flag) => [flag, false]));
|
||||||
const reasons = [];
|
const reasons = [];
|
||||||
const full = (reason) => {
|
const full = (reason) => {
|
||||||
@@ -122,6 +140,7 @@ if (
|
|||||||
file === ".github/actionlint.yaml"
|
file === ".github/actionlint.yaml"
|
||||||
) {
|
) {
|
||||||
selected.workflows = true;
|
selected.workflows = true;
|
||||||
|
if (GUARDED_WORKFLOWS.has(file)) selected.code = true;
|
||||||
if (file !== ".github/workflows/ci.yml") continue;
|
if (file !== ".github/workflows/ci.yml") continue;
|
||||||
}
|
}
|
||||||
if (owners.length === 1 && owners[0] === "website") continue;
|
if (owners.length === 1 && owners[0] === "website") continue;
|
||||||
@@ -131,6 +150,11 @@ if (
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
selected.code = true;
|
selected.code = true;
|
||||||
|
if (matchesAny(file, FIXTURE_SOURCES)) {
|
||||||
|
selected.fixtures = true;
|
||||||
|
selected.readiness = true;
|
||||||
|
}
|
||||||
|
if (matchesAny(file, READINESS_SOURCES)) selected.readiness = true;
|
||||||
if (file.startsWith("tests/")) continue;
|
if (file.startsWith("tests/")) continue;
|
||||||
if (
|
if (
|
||||||
/^(src\/process\/|src\/filesystem\/|src\/contracts\/tool|src\/server\/createServer|src\/cli\.ts|scripts\/ci\/)/.test(
|
/^(src\/process\/|src\/filesystem\/|src\/contracts\/tool|src\/server\/createServer|src\/cli\.ts|scripts\/ci\/)/.test(
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
@@ -20,23 +20,56 @@ export async function buildRecoveryFixtures(root, prefix) {
|
|||||||
const source = fileURLToPath(new URL("./entry.mjs", import.meta.url));
|
const source = fileURLToPath(new URL("./entry.mjs", import.meta.url));
|
||||||
const webpackOutput = join(root, "webpack");
|
const webpackOutput = join(root, "webpack");
|
||||||
await mkdir(webpackOutput);
|
await mkdir(webpackOutput);
|
||||||
await new Promise((resolve, reject) => {
|
const compileWebpack = async (options) =>
|
||||||
const compiler = webpack({
|
new Promise((resolve, reject) => {
|
||||||
mode: "production",
|
const compiler = webpack(options);
|
||||||
entry: source,
|
compiler.run((error, stats) => {
|
||||||
parallelism: 1,
|
compiler.close((closeError) => {
|
||||||
optimization: { concatenateModules: false },
|
if (error || closeError) return reject(error ?? closeError);
|
||||||
output: { path: webpackOutput, filename: "bundle.js" },
|
if (!stats || stats.hasErrors())
|
||||||
});
|
return reject(new Error(stats?.toString() ?? "No webpack stats"));
|
||||||
compiler.run((error, stats) => {
|
resolve();
|
||||||
compiler.close((closeError) => {
|
});
|
||||||
if (error || closeError) return reject(error ?? closeError);
|
|
||||||
if (!stats || stats.hasErrors())
|
|
||||||
return reject(new Error(stats?.toString() ?? "No webpack stats"));
|
|
||||||
resolve();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
await compileWebpack({
|
||||||
|
mode: "production",
|
||||||
|
entry: source,
|
||||||
|
parallelism: 1,
|
||||||
|
optimization: { concatenateModules: false },
|
||||||
|
output: { path: webpackOutput, filename: "bundle.js" },
|
||||||
});
|
});
|
||||||
|
const splitOutput = join(root, "webpack-code-split");
|
||||||
|
await mkdir(splitOutput);
|
||||||
|
const splitSource = fileURLToPath(
|
||||||
|
new URL("./code-split/entry.mjs", import.meta.url),
|
||||||
|
);
|
||||||
|
await compileWebpack({
|
||||||
|
mode: "production",
|
||||||
|
entry: splitSource,
|
||||||
|
parallelism: 1,
|
||||||
|
optimization: {
|
||||||
|
concatenateModules: false,
|
||||||
|
runtimeChunk: "single",
|
||||||
|
splitChunks: {
|
||||||
|
chunks: "all",
|
||||||
|
minSize: 0,
|
||||||
|
cacheGroups: {
|
||||||
|
shared: { test: /shared\.mjs$/u, name: "shared", enforce: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
path: splitOutput,
|
||||||
|
filename: "[name].js",
|
||||||
|
chunkFilename: "[id].chunk.js",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const splitFiles = (await readdir(splitOutput))
|
||||||
|
.filter((name) => name !== "main.js")
|
||||||
|
.sort();
|
||||||
|
if (splitFiles.length !== 3)
|
||||||
|
throw new Error("Expected runtime, shared and lazy chunks");
|
||||||
const esbuildPath = join(root, "esbuild.js");
|
const esbuildPath = join(root, "esbuild.js");
|
||||||
await esbuild.build({
|
await esbuild.build({
|
||||||
entryPoints: [source],
|
entryPoints: [source],
|
||||||
@@ -95,6 +128,13 @@ export async function buildRecoveryFixtures(root, prefix) {
|
|||||||
options: { extraction_mode: "inspection", rewrite_level: "minimal" },
|
options: { extraction_mode: "inspection", rewrite_level: "minimal" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
codeSplit: {
|
||||||
|
name: "webpack-code-split",
|
||||||
|
path: join(splitOutput, "main.js"),
|
||||||
|
additional_paths: splitFiles.map((name) => join(splitOutput, name)),
|
||||||
|
expectedFormat: "webpack5",
|
||||||
|
expectedModules: 4,
|
||||||
|
},
|
||||||
toolchains: { esbuild: esbuild.version, webpack: webpackVersion },
|
toolchains: { esbuild: esbuild.version, webpack: webpackVersion },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { Cart, formatPrice, TAX_RATE } from "./shared.mjs";
|
||||||
|
export function checkout(prices) {
|
||||||
|
const cart = new Cart();
|
||||||
|
prices.forEach((price) => cart.add(price));
|
||||||
|
return formatPrice(cart.total() * (1 + TAX_RATE));
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { formatPrice } from "./shared.mjs";
|
||||||
|
console.log(formatPrice(1));
|
||||||
|
import("./checkout.mjs").then(({ checkout }) => console.log(checkout([1, 2])));
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
export class Cart {
|
||||||
|
constructor() {
|
||||||
|
this.items = [];
|
||||||
|
}
|
||||||
|
add(price) {
|
||||||
|
this.items.push(price);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
total() {
|
||||||
|
return this.items.reduce((sum, price) => sum + price, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export const formatPrice = (value) => `$${value.toFixed(2)}`;
|
||||||
|
export const TAX_RATE = 0.08;
|
||||||
@@ -99,6 +99,14 @@ const scenarioFor = (browser, origin) =>
|
|||||||
capture: { after_each_step: ["dom"], at_end: ["dom"] },
|
capture: { after_each_step: ["dom"], at_end: ["dom"] },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const raceWithTimeout = (operation, timeoutMs, onTimeout = () => undefined) => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
return Promise.race([
|
||||||
|
operation,
|
||||||
|
delay(timeoutMs, undefined, { signal: controller.signal }).then(onTimeout),
|
||||||
|
]).finally(() => controller.abort());
|
||||||
|
};
|
||||||
|
|
||||||
const storagePage = (kind, port) => {
|
const storagePage = (kind, port) => {
|
||||||
if (kind === "main")
|
if (kind === "main")
|
||||||
return `<!doctype html><html><body><pre id="main-report"></pre><pre id="frame-report"></pre><iframe src="http://localhost:${port}/storage-frame"></iframe><script>
|
return `<!doctype html><html><body><pre id="main-report"></pre><pre id="frame-report"></pre><iframe src="http://localhost:${port}/storage-frame"></iframe><script>
|
||||||
@@ -168,6 +176,9 @@ const startStorageSite = async () => {
|
|||||||
origin: `http://127.0.0.1:${port}`,
|
origin: `http://127.0.0.1:${port}`,
|
||||||
close: async () => {
|
close: async () => {
|
||||||
server.close();
|
server.close();
|
||||||
|
// Chrome can leave speculative TCP connections without an HTTP request;
|
||||||
|
// server.close() alone waits indefinitely for those owned sockets.
|
||||||
|
server.closeAllConnections();
|
||||||
await once(server, "close");
|
await once(server, "close");
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -303,11 +314,10 @@ const verifyConnectedStorage = async (endpoint, targetId, origin) => {
|
|||||||
await verifyCallerDebuggerPause(observer, origin, scenario);
|
await verifyCallerDebuggerPause(observer, origin, scenario);
|
||||||
await verifyCancelledStorage(observer, endpoint, targetId, scenario);
|
await verifyCancelledStorage(observer, endpoint, targetId, scenario);
|
||||||
} finally {
|
} finally {
|
||||||
await Promise.race([
|
await raceWithTimeout(restoreTarget(observer, target.url), 1_000).catch(
|
||||||
restoreTarget(observer, target.url),
|
() => undefined,
|
||||||
delay(1_000),
|
);
|
||||||
]).catch(() => undefined);
|
await raceWithTimeout(observer.close(), 1_000).catch(() => undefined);
|
||||||
await Promise.race([observer.close(), delay(1_000)]).catch(() => undefined);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -500,14 +510,11 @@ const verifyCallerDebuggerPause = async (caller, origin, scenario) => {
|
|||||||
unsubscribe();
|
unsubscribe();
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const callFrameId = await Promise.race([
|
const callFrameId = await raceWithTimeout(paused, 10_000, () => {
|
||||||
paused,
|
throw new Error(
|
||||||
delay(10_000).then(() => {
|
`Caller debugger pause was not observed at ${debuggerUrl}; settled=${String(settled)} events=${JSON.stringify(protocolTrace)}`,
|
||||||
throw new Error(
|
);
|
||||||
`Caller debugger pause was not observed at ${debuggerUrl}; settled=${String(settled)} events=${JSON.stringify(protocolTrace)}`,
|
});
|
||||||
);
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
const pausedState = await caller.send("Debugger.evaluateOnCallFrame", {
|
const pausedState = await caller.send("Debugger.evaluateOnCallFrame", {
|
||||||
callFrameId,
|
callFrameId,
|
||||||
expression: "window.reaDebuggerAfter === true",
|
expression: "window.reaDebuggerAfter === true",
|
||||||
@@ -536,10 +543,10 @@ const verifyCallerDebuggerPause = async (caller, origin, scenario) => {
|
|||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (!pauseObserved) controller.abort(cause);
|
if (!pauseObserved) controller.abort(cause);
|
||||||
else await caller.send("Debugger.resume").catch(() => undefined);
|
else await caller.send("Debugger.resume").catch(() => undefined);
|
||||||
const completion = await Promise.race([
|
const completion = await raceWithTimeout(
|
||||||
pending.catch((pendingCause) => ({ ok: false, error: pendingCause })),
|
pending.catch((pendingCause) => ({ ok: false, error: pendingCause })),
|
||||||
delay(5_000).then(() => undefined),
|
5_000,
|
||||||
]);
|
);
|
||||||
if (completion === undefined)
|
if (completion === undefined)
|
||||||
throw new Error("Capture remained pending after cancellation", { cause });
|
throw new Error("Capture remained pending after cancellation", { cause });
|
||||||
const completionError =
|
const completionError =
|
||||||
@@ -579,7 +586,7 @@ const verifyCallerDebuggerPause = async (caller, origin, scenario) => {
|
|||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
await Promise.race([caller.send("Debugger.disable"), delay(1_000)]).catch(
|
await raceWithTimeout(caller.send("Debugger.disable"), 1_000).catch(
|
||||||
() => undefined,
|
() => undefined,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -277,11 +277,13 @@ try {
|
|||||||
throw new Error("Scenario attachment terminated its external browser");
|
throw new Error("Scenario attachment terminated its external browser");
|
||||||
|
|
||||||
const profilesBefore = await scenarioProfiles();
|
const profilesBefore = await scenarioProfiles();
|
||||||
|
process.stderr.write("Browser verifier: scenario environment\n");
|
||||||
const scenarioEnvironment = await verifyScenarioEnvironment(
|
const scenarioEnvironment = await verifyScenarioEnvironment(
|
||||||
endpoint,
|
endpoint,
|
||||||
target,
|
target,
|
||||||
site.origin,
|
site.origin,
|
||||||
);
|
);
|
||||||
|
process.stderr.write("Browser verifier: scenario storage\n");
|
||||||
const scenarioStorage = await verifyScenarioStorage({
|
const scenarioStorage = await verifyScenarioStorage({
|
||||||
executable,
|
executable,
|
||||||
endpoint,
|
endpoint,
|
||||||
@@ -339,7 +341,9 @@ try {
|
|||||||
`Browser scenario included unselected structured value: ${unselectedValue}`,
|
`Browser scenario included unselected structured value: ${unselectedValue}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
process.stderr.write("Browser verifier: large screenshot\n");
|
||||||
const largeScreenshot = await verifyLargeScreenshotE2e(endpoint, site.origin);
|
const largeScreenshot = await verifyLargeScreenshotE2e(endpoint, site.origin);
|
||||||
|
process.stderr.write("Browser verifier: popup coverage\n");
|
||||||
const popupEvents = await verifyPopupEventCoverage(executable);
|
const popupEvents = await verifyPopupEventCoverage(executable);
|
||||||
const networkEvidence = await verifyBrowserNetworkEvidence(
|
const networkEvidence = await verifyBrowserNetworkEvidence(
|
||||||
executable,
|
executable,
|
||||||
|
|||||||
@@ -674,6 +674,113 @@ export async function verifyRelativeSwitch(client, procedures, entrySize) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Oracle for relative-switch-signed.S: signed halfword byte offsets from a base
|
||||||
|
* inside the case bodies, a CBZ route for the zero entry, and a B.CS bound.
|
||||||
|
*/
|
||||||
|
const SIGNED_RELATIVE_SWITCH_CASES = [
|
||||||
|
{ value: 0, immediate: 200 },
|
||||||
|
{ value: 1, immediate: 201 },
|
||||||
|
{ value: 2, immediate: 202 },
|
||||||
|
{ value: 3, immediate: 250 },
|
||||||
|
{ value: 4, immediate: 204 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function verifyRelativeSignedSwitch(client, selectorBias = 0) {
|
||||||
|
const names = await inventoryCall(client, "list_names", {
|
||||||
|
document: null,
|
||||||
|
address: null,
|
||||||
|
});
|
||||||
|
const procedure = requireProcedure(names, "rea_relative_switch_signed");
|
||||||
|
const dossier = await functionCall(client, "analyze_function", {
|
||||||
|
procedure: procedure.address,
|
||||||
|
});
|
||||||
|
const table = dossier.native_api?.jump_tables.find((item) =>
|
||||||
|
item.data_sources.some(
|
||||||
|
(source) =>
|
||||||
|
source.entry_size_bytes === 2 &&
|
||||||
|
source.entry_count === SIGNED_RELATIVE_SWITCH_CASES.length,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (table === undefined)
|
||||||
|
throw new Error(
|
||||||
|
`Exact signed halfword relative table unavailable: ${JSON.stringify(dossier.native_api)}`,
|
||||||
|
);
|
||||||
|
const expectedValues = SIGNED_RELATIVE_SWITCH_CASES.map(
|
||||||
|
({ value }) => value + selectorBias,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
table.mappings.some(
|
||||||
|
(mapping) =>
|
||||||
|
mapping.case_value !== null &&
|
||||||
|
!expectedValues.includes(mapping.case_value),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
`Signed relative switch emitted an unsupported case label: ${JSON.stringify(table)}`,
|
||||||
|
);
|
||||||
|
if (table.mappings.length !== SIGNED_RELATIVE_SWITCH_CASES.length)
|
||||||
|
throw new Error(
|
||||||
|
`Signed relative switch has unexpected mappings: ${JSON.stringify(table)}`,
|
||||||
|
);
|
||||||
|
if (selectorBias !== 0) {
|
||||||
|
if (
|
||||||
|
!table.limitations.some((limitation) =>
|
||||||
|
limitation.includes("table indices are not proven"),
|
||||||
|
) ||
|
||||||
|
!table.mappings.some((mapping) => mapping.case_value === null) ||
|
||||||
|
table.mappings.some((mapping) => mapping.case_value === selectorBias + 3)
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
`Normalized selector must retain unknown case evidence: ${JSON.stringify(table)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const { value: tableIndex, immediate } of SIGNED_RELATIVE_SWITCH_CASES) {
|
||||||
|
const value = tableIndex + selectorBias;
|
||||||
|
// Without a proven relationship between the machine index and the typed
|
||||||
|
// switch selector, the CBZ-only case must stay unknown rather than being
|
||||||
|
// assigned the normalized index or Ghidra's unreachable base destination.
|
||||||
|
if (selectorBias !== 0 && tableIndex === 3) continue;
|
||||||
|
const mapping = table.mappings.find((item) => item.case_value === value);
|
||||||
|
if (
|
||||||
|
mapping === undefined ||
|
||||||
|
mapping.confidence !== "high" ||
|
||||||
|
(selectorBias !== 0 &&
|
||||||
|
!mapping.evidence.some(
|
||||||
|
(evidence) => evidence.source === "ghidra-clang-case-token",
|
||||||
|
))
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
`Signed relative switch case ${value} was not verified: ${JSON.stringify(table)}`,
|
||||||
|
);
|
||||||
|
const instruction = await functionCall(
|
||||||
|
client,
|
||||||
|
"inspect_native_instruction",
|
||||||
|
{ address: mapping.target_address },
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
instruction.status !== "decoded" ||
|
||||||
|
!instruction.operands.some((operand) =>
|
||||||
|
operand.components.some(
|
||||||
|
(component) =>
|
||||||
|
component.kind === "immediate" &&
|
||||||
|
component.value === `0x${immediate.toString(16)}`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
`Signed relative switch target does not implement source case ${value}: ${JSON.stringify(instruction)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
entry_size: 2,
|
||||||
|
signed: true,
|
||||||
|
selector_bias: selectorBias,
|
||||||
|
dispatch_address: table.dispatch_address,
|
||||||
|
mappings: table.mappings.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function verifyNativeValueTrace(client, procedures, target) {
|
export async function verifyNativeValueTrace(client, procedures, target) {
|
||||||
const entry = requireProcedure(procedures, "rea_ghidra_inventory_entry");
|
const entry = requireProcedure(procedures, "rea_ghidra_inventory_entry");
|
||||||
const names = await inventoryCall(client, "list_names", {
|
const names = await inventoryCall(client, "list_names", {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
verifyJumpTableDenseSwitch,
|
verifyJumpTableDenseSwitch,
|
||||||
verifyNativeTypeLayout,
|
verifyNativeTypeLayout,
|
||||||
verifyRelativeSwitch,
|
verifyRelativeSwitch,
|
||||||
|
verifyRelativeSignedSwitch,
|
||||||
verifyNativeValueTrace,
|
verifyNativeValueTrace,
|
||||||
} from "./verify-real-ghidra-function.mjs";
|
} from "./verify-real-ghidra-function.mjs";
|
||||||
const exec = promisify(execFile);
|
const exec = promisify(execFile);
|
||||||
@@ -173,6 +174,36 @@ try {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const signedRelativeSource = fileURLToPath(
|
||||||
|
new URL(
|
||||||
|
"../tests/conformance/ghidra/relative-switch-signed.S",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const selectorBias of [0, 2, 10]) {
|
||||||
|
for (const format of [
|
||||||
|
"elf",
|
||||||
|
...(expectedNativeTarget.architecture === "arm64" ? ["mach-o"] : []),
|
||||||
|
]) {
|
||||||
|
const targetPath = join(
|
||||||
|
fixtureRoot,
|
||||||
|
`relative-signed-${selectorBias}-${format}.o`,
|
||||||
|
);
|
||||||
|
await exec(clang, [
|
||||||
|
...(format === "elf" ? ["--target=aarch64-linux-gnu"] : []),
|
||||||
|
`-DREA_SELECTOR_BIAS=${selectorBias}`,
|
||||||
|
"-c",
|
||||||
|
signedRelativeSource,
|
||||||
|
"-o",
|
||||||
|
targetPath,
|
||||||
|
]);
|
||||||
|
crossTargets.push([
|
||||||
|
targetPath,
|
||||||
|
`relative-signed-${selectorBias}`,
|
||||||
|
{ format, architecture: "arm64" },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await exec(clang, [
|
await exec(clang, [
|
||||||
"--target=aarch64-linux-gnu",
|
"--target=aarch64-linux-gnu",
|
||||||
@@ -522,25 +553,30 @@ async function verifyTarget(targetPath, variant, expectedTarget = null) {
|
|||||||
client,
|
client,
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
const probes = variant.startsWith("relative-")
|
const probes = variant.startsWith("relative-signed-")
|
||||||
? await verifyRelativeSwitch(
|
? await verifyRelativeSignedSwitch(
|
||||||
client,
|
client,
|
||||||
procedures,
|
Number(variant.split("-").at(-1)),
|
||||||
Number(variant.slice(-1)),
|
|
||||||
)
|
)
|
||||||
: variant === "type-layout"
|
: variant.startsWith("relative-")
|
||||||
? await verifyNativeTypeLayout(client, names)
|
? await verifyRelativeSwitch(
|
||||||
: variant === "jump-table"
|
client,
|
||||||
? await verifyJumpTableDenseSwitch(client, procedures)
|
procedures,
|
||||||
: variant === "custom" || variant === "aarch64-jump-table"
|
Number(variant.slice(-1)),
|
||||||
? null
|
)
|
||||||
: await verifyInventoryOperations({
|
: variant === "type-layout"
|
||||||
client,
|
? await verifyNativeTypeLayout(client, names)
|
||||||
variant,
|
: variant === "jump-table"
|
||||||
procedures,
|
? await verifyJumpTableDenseSwitch(client, procedures)
|
||||||
names,
|
: variant === "custom" || variant === "aarch64-jump-table"
|
||||||
strings,
|
? null
|
||||||
});
|
: await verifyInventoryOperations({
|
||||||
|
client,
|
||||||
|
variant,
|
||||||
|
procedures,
|
||||||
|
names,
|
||||||
|
strings,
|
||||||
|
});
|
||||||
const nativeValues =
|
const nativeValues =
|
||||||
variant === "debug"
|
variant === "debug"
|
||||||
? await verifyNativeValueTrace(client, procedures, parsedTarget.value)
|
? await verifyNativeValueTrace(client, procedures, parsedTarget.value)
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ try {
|
|||||||
process.env.REA_JAVASCRIPT_FIXTURE_TOOLS,
|
process.env.REA_JAVASCRIPT_FIXTURE_TOOLS,
|
||||||
);
|
);
|
||||||
await client.connect(transport);
|
await client.connect(transport);
|
||||||
for (const fixture of fixtures.files) {
|
for (const fixture of [...fixtures.files, fixtures.codeSplit]) {
|
||||||
for (const surface of ["cli", "stdio_mcp"]) {
|
for (const surface of ["cli", "stdio_mcp"]) {
|
||||||
const output = join(root, `${fixture.name}-${surface}`);
|
const output = join(root, `${fixture.name}-${surface}`);
|
||||||
const flags =
|
const flags =
|
||||||
@@ -75,11 +75,18 @@ try {
|
|||||||
fixture.path,
|
fixture.path,
|
||||||
output,
|
output,
|
||||||
...flags,
|
...flags,
|
||||||
|
...(fixture.additional_paths ?? []).flatMap((path) => [
|
||||||
|
"--additional-path",
|
||||||
|
path,
|
||||||
|
]),
|
||||||
)
|
)
|
||||||
: await call("recover_javascript_sources", {
|
: await call("recover_javascript_sources", {
|
||||||
path: fixture.path,
|
path: fixture.path,
|
||||||
output_directory: output,
|
output_directory: output,
|
||||||
...fixture.options,
|
...fixture.options,
|
||||||
|
...(fixture.additional_paths
|
||||||
|
? { additional_paths: fixture.additional_paths }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
const result = javascriptRecoveryResultSchema.parse(
|
const result = javascriptRecoveryResultSchema.parse(
|
||||||
evidence.normalized_result,
|
evidence.normalized_result,
|
||||||
@@ -97,6 +104,25 @@ try {
|
|||||||
analysis.normalized_result.statistics.parsed_javascript_files,
|
analysis.normalized_result.statistics.parsed_javascript_files,
|
||||||
result.modules.length,
|
result.modules.length,
|
||||||
);
|
);
|
||||||
|
const imports = analysis.normalized_result.graph.edges.filter(
|
||||||
|
(edge) =>
|
||||||
|
edge.relation === "imports" &&
|
||||||
|
edge.properties.resolution_status === "resolved",
|
||||||
|
);
|
||||||
|
if (fixture.additional_paths) {
|
||||||
|
assert.ok(
|
||||||
|
imports.length >= 4,
|
||||||
|
"Expected cross-chunk imports in the recovered application graph",
|
||||||
|
);
|
||||||
|
const recoveredPaths = new Set(
|
||||||
|
result.modules.map((module) => module.reported_filename),
|
||||||
|
);
|
||||||
|
for (const edge of imports)
|
||||||
|
assert.ok(
|
||||||
|
recoveredPaths.has(edge.properties.resolved_path),
|
||||||
|
`Expected an emitted import target: ${String(edge.properties.resolved_path)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
const recoveredEntry =
|
const recoveredEntry =
|
||||||
result.modules.find(
|
result.modules.find(
|
||||||
(module) => module.reported_filename === "entry.js",
|
(module) => module.reported_filename === "entry.js",
|
||||||
@@ -104,7 +130,8 @@ try {
|
|||||||
assert.ok(recoveredEntry);
|
assert.ok(recoveredEntry);
|
||||||
const oracle =
|
const oracle =
|
||||||
'await import((await import("node:url")).pathToFileURL(process.argv[1])); const f=globalThis.recoveryFixture; console.log(JSON.stringify([f.greet(null),f.greet("REA"),f.total([1,2,3])]));';
|
'await import((await import("node:url")).pathToFileURL(process.argv[1])); const f=globalThis.recoveryFixture; console.log(JSON.stringify([f.greet(null),f.greet("REA"),f.total([1,2,3])]));';
|
||||||
for (const selected of fixture.options?.extraction_mode === "inspection"
|
for (const selected of fixture.additional_paths ||
|
||||||
|
fixture.options?.extraction_mode === "inspection"
|
||||||
? []
|
? []
|
||||||
: [fixture.path, recoveredEntry.artifact.path]) {
|
: [fixture.path, recoveredEntry.artifact.path]) {
|
||||||
const execution = await run(
|
const execution = await run(
|
||||||
@@ -125,7 +152,9 @@ try {
|
|||||||
maps: result.modules.filter((module) => module.source_map !== null)
|
maps: result.modules.filter((module) => module.source_map !== null)
|
||||||
.length,
|
.length,
|
||||||
finite_fixture_oracle:
|
finite_fixture_oracle:
|
||||||
|
!fixture.additional_paths &&
|
||||||
fixture.options?.extraction_mode !== "inspection",
|
fixture.options?.extraction_mode !== "inspection",
|
||||||
|
cross_chunk_imports: fixture.additional_paths ? imports.length : null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,9 +186,15 @@ async function assertRecoveredArtifacts(evidence, result, fixture) {
|
|||||||
assert.equal(result.status, "complete");
|
assert.equal(result.status, "complete");
|
||||||
assert.equal(result.reported_format, fixture.expectedFormat);
|
assert.equal(result.reported_format, fixture.expectedFormat);
|
||||||
assert.equal(result.modules.length, fixture.expectedModules);
|
assert.equal(result.modules.length, fixture.expectedModules);
|
||||||
assert.equal(result.source.sha256, fingerprint(await readFile(fixture.path)));
|
const sources = [result.source, ...(result.additional_sources ?? [])];
|
||||||
|
assert.deepEqual(
|
||||||
|
sources.map((source) => source.path),
|
||||||
|
[fixture.path, ...(fixture.additional_paths ?? [])],
|
||||||
|
);
|
||||||
|
for (const source of sources)
|
||||||
|
assert.equal(source.sha256, fingerprint(await readFile(source.path)));
|
||||||
for (const file of [
|
for (const file of [
|
||||||
result.source.published_copy,
|
...sources.map((source) => source.published_copy),
|
||||||
result.report,
|
result.report,
|
||||||
result.provenance,
|
result.provenance,
|
||||||
result.manifest,
|
result.manifest,
|
||||||
@@ -173,16 +208,27 @@ async function assertRecoveredArtifacts(evidence, result, fixture) {
|
|||||||
assert.equal(fingerprint(bytes), file.sha256);
|
assert.equal(fingerprint(bytes), file.sha256);
|
||||||
}
|
}
|
||||||
for (const module of result.modules) {
|
for (const module of result.modules) {
|
||||||
assert.equal(module.provenance.original_sha256, result.source.sha256);
|
const source = sources.find(
|
||||||
assert.equal(module.provenance.reported_input, result.source.snapshot_path);
|
(source) => source.snapshot_path === module.provenance.reported_input,
|
||||||
for (const range of module.provenance.byte_ranges)
|
);
|
||||||
assert.ok(range.start <= range.end && range.end <= result.source.bytes);
|
assert.ok(source, "Module must name a selected input snapshot");
|
||||||
|
assert.equal(module.provenance.original_path, source.path);
|
||||||
|
assert.equal(module.provenance.original_sha256, source.sha256);
|
||||||
|
for (const range of [
|
||||||
|
...module.provenance.byte_ranges,
|
||||||
|
...module.provenance.context_byte_ranges,
|
||||||
|
])
|
||||||
|
assert.ok(range.start <= range.end && range.end <= source.bytes);
|
||||||
if (module.source_map) {
|
if (module.source_map) {
|
||||||
const map = JSON.parse(await readFile(module.source_map.path, "utf8"));
|
const map = JSON.parse(await readFile(module.source_map.path, "utf8"));
|
||||||
for (const source of map.sources)
|
for (const source of map.sources)
|
||||||
assert.equal(
|
assert.ok(
|
||||||
resolve(dirname(module.source_map.path), source),
|
sources.some(
|
||||||
result.source.published_copy.path,
|
(selected) =>
|
||||||
|
selected.published_copy.path ===
|
||||||
|
resolve(dirname(module.source_map.path), source),
|
||||||
|
),
|
||||||
|
"Map source must name a published input snapshot",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
staticInferenceEvidence,
|
staticInferenceEvidence,
|
||||||
} from "./JavaScriptArtifactGraphEvidence.js";
|
} from "./JavaScriptArtifactGraphEvidence.js";
|
||||||
import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js";
|
import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js";
|
||||||
|
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
|
||||||
|
|
||||||
interface PackageRoleInput {
|
interface PackageRoleInput {
|
||||||
readonly packageNode: ApplicationNode;
|
readonly packageNode: ApplicationNode;
|
||||||
@@ -281,6 +282,7 @@ export const addJavaScriptPackageNodes = (
|
|||||||
context: JavaScriptArtifactGraphContext,
|
context: JavaScriptArtifactGraphContext,
|
||||||
): ApplicationNode[] => {
|
): ApplicationNode[] => {
|
||||||
const roots: ApplicationNode[] = [];
|
const roots: ApplicationNode[] = [];
|
||||||
|
const rootPath = applicationManifestPath(context);
|
||||||
for (const packageValue of context.analysis.packages) {
|
for (const packageValue of context.analysis.packages) {
|
||||||
const file = context.filesByPath.get(packageValue.path);
|
const file = context.filesByPath.get(packageValue.path);
|
||||||
if (file === undefined) continue;
|
if (file === undefined) continue;
|
||||||
@@ -309,7 +311,7 @@ export const addJavaScriptPackageNodes = (
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
if (roots.length === 0) {
|
if (file.path === rootPath) {
|
||||||
roots.push(node);
|
roots.push(node);
|
||||||
context.accumulator.addEdge({
|
context.accumulator.addEdge({
|
||||||
source_node_id: node.node_id,
|
source_node_id: node.node_id,
|
||||||
@@ -330,6 +332,7 @@ export const addJavaScriptPackageNodes = (
|
|||||||
file,
|
file,
|
||||||
operation: "inventory-package",
|
operation: "inventory-package",
|
||||||
});
|
});
|
||||||
|
if (dependencyManifest(file.path)) continue;
|
||||||
addPackageRole(context, {
|
addPackageRole(context, {
|
||||||
packageNode: node,
|
packageNode: node,
|
||||||
packageFile: file,
|
packageFile: file,
|
||||||
@@ -346,6 +349,24 @@ export const addJavaScriptPackageNodes = (
|
|||||||
return roots;
|
return roots;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Dependency-only artifacts retain their inventory root. */
|
||||||
|
const applicationManifestPath = (
|
||||||
|
context: JavaScriptArtifactGraphContext,
|
||||||
|
): string | undefined =>
|
||||||
|
context.analysis.packages
|
||||||
|
.map(({ path }) => path)
|
||||||
|
.filter(
|
||||||
|
(path) => context.filesByPath.has(path) && !dependencyManifest(path),
|
||||||
|
)
|
||||||
|
.sort(
|
||||||
|
(left, right) =>
|
||||||
|
left.split("/").length - right.split("/").length ||
|
||||||
|
compareUnicodeCodePoints(left, right),
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
const dependencyManifest = (path: string): boolean =>
|
||||||
|
path.split("/").slice(0, -1).includes("node_modules");
|
||||||
|
|
||||||
const createFileTarget = (
|
const createFileTarget = (
|
||||||
context: JavaScriptArtifactGraphContext,
|
context: JavaScriptArtifactGraphContext,
|
||||||
file: JavaScriptArtifactFile,
|
file: JavaScriptArtifactFile,
|
||||||
|
|||||||
@@ -129,4 +129,50 @@ describe("JavaScript recovery workflow", () => {
|
|||||||
if (cancelled.ok) throw new Error("expected cancellation");
|
if (cancelled.ok) throw new Error("expected cancellation");
|
||||||
expect(cancelled.error._tag).toBe("AnalysisCancelledError");
|
expect(cancelled.error._tag).toBe("AnalysisCancelledError");
|
||||||
});
|
});
|
||||||
|
it("rejects additional_paths repeating path or each other", async () => {
|
||||||
|
const service = new JavaScriptRecoveryService({
|
||||||
|
recover: async () => {
|
||||||
|
throw new Error("port must not execute");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const additional_paths of [
|
||||||
|
[artifact.path],
|
||||||
|
["/tmp/dir/../bundle.js", "/tmp/bundle.js"],
|
||||||
|
["/tmp/shared.js", "/tmp/shared.js"],
|
||||||
|
]) {
|
||||||
|
const result = await service.recover({
|
||||||
|
...input,
|
||||||
|
additional_paths,
|
||||||
|
});
|
||||||
|
if (result.ok) throw new Error("expected duplicate rejection");
|
||||||
|
expect(result.error._tag).toBe("AnalysisInputError");
|
||||||
|
expect(result.error).toMatchObject({
|
||||||
|
issues: [{ path: ["additional_paths"], reason: "invalid_format" }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it("rejects relative additional_paths before acquiring the port", async () => {
|
||||||
|
let invoked = false;
|
||||||
|
const service = new JavaScriptRecoveryService({
|
||||||
|
recover: async () => {
|
||||||
|
invoked = true;
|
||||||
|
return err(
|
||||||
|
new AnalysisCapabilityUnavailableError(
|
||||||
|
"fixture",
|
||||||
|
"recover_javascript_sources",
|
||||||
|
"engine missing",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await service.recover({
|
||||||
|
...input,
|
||||||
|
additional_paths: ["relative-chunk.js"],
|
||||||
|
});
|
||||||
|
expect(invoked).toBe(false);
|
||||||
|
if (result.ok) throw new Error("expected invalid path");
|
||||||
|
expect(result.error).toMatchObject({
|
||||||
|
issues: [{ path: ["additional_paths"], reason: "invalid_format" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { isAbsolute } from "node:path";
|
import { isAbsolute, resolve } from "node:path";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AnalysisCancelledError,
|
AnalysisCancelledError,
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
javascriptRecoveryInputSchema,
|
javascriptRecoveryInputSchema,
|
||||||
javascriptRecoveryResultSchema,
|
javascriptRecoveryResultSchema,
|
||||||
} from "../../domain/javascript/javascriptRecovery.js";
|
} from "../../domain/javascript/javascriptRecovery.js";
|
||||||
import { jsonObjectSchema } from "../../domain/jsonValue.js";
|
import { jsonObjectSchema, jsonValueSchema } from "../../domain/jsonValue.js";
|
||||||
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
|
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
|
||||||
import { err, ok, type Result } from "../../domain/result.js";
|
import { err, ok, type Result } from "../../domain/result.js";
|
||||||
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
||||||
@@ -44,17 +44,38 @@ export class JavaScriptRecoveryService {
|
|||||||
const invalidPaths = (["path", "output_directory"] as const).filter(
|
const invalidPaths = (["path", "output_directory"] as const).filter(
|
||||||
(field) => !isAbsolute(parsed.data[field]),
|
(field) => !isAbsolute(parsed.data[field]),
|
||||||
);
|
);
|
||||||
if (invalidPaths.length > 0)
|
const invalidAdditional = (parsed.data.additional_paths ?? []).filter(
|
||||||
|
(additional) => !isAbsolute(additional),
|
||||||
|
);
|
||||||
|
if (invalidPaths.length > 0 || invalidAdditional.length > 0)
|
||||||
return err(
|
return err(
|
||||||
new AnalysisInputError(
|
new AnalysisInputError(OPERATION, undefined, [
|
||||||
OPERATION,
|
...invalidPaths.map((field) => ({
|
||||||
undefined,
|
|
||||||
invalidPaths.map((field) => ({
|
|
||||||
path: [field],
|
path: [field],
|
||||||
reason: "invalid_format",
|
reason: "invalid_format" as const,
|
||||||
message: `${field} must be an absolute filesystem path on this host.`,
|
message: `${field} must be an absolute filesystem path on this host.`,
|
||||||
})),
|
})),
|
||||||
),
|
...invalidAdditional.map((additional) => ({
|
||||||
|
path: ["additional_paths"],
|
||||||
|
reason: "invalid_format" as const,
|
||||||
|
message: `additional_paths entries must be absolute filesystem paths on this host: ${additional}`,
|
||||||
|
})),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const selectedInputs = new Set(
|
||||||
|
[parsed.data.path, ...(parsed.data.additional_paths ?? [])].map((path) =>
|
||||||
|
resolve(path),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (selectedInputs.size !== 1 + (parsed.data.additional_paths ?? []).length)
|
||||||
|
return err(
|
||||||
|
new AnalysisInputError(OPERATION, undefined, [
|
||||||
|
{
|
||||||
|
path: ["additional_paths"],
|
||||||
|
reason: "invalid_format",
|
||||||
|
message: "additional_paths must not repeat path or each other.",
|
||||||
|
},
|
||||||
|
]),
|
||||||
);
|
);
|
||||||
const execution = await this.provider.recover(parsed.data, options);
|
const execution = await this.provider.recover(parsed.data, options);
|
||||||
if (!execution.ok) return execution;
|
if (!execution.ok) return execution;
|
||||||
@@ -70,6 +91,11 @@ export class JavaScriptRecoveryService {
|
|||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
result.data.source.path !== parsed.data.path ||
|
result.data.source.path !== parsed.data.path ||
|
||||||
|
(result.data.additional_sources ?? []).length !==
|
||||||
|
(parsed.data.additional_paths ?? []).length ||
|
||||||
|
(parsed.data.additional_paths ?? []).some(
|
||||||
|
(path, index) => result.data.additional_sources?.[index]?.path !== path,
|
||||||
|
) ||
|
||||||
execution.value.subject.path !== result.data.source.path ||
|
execution.value.subject.path !== result.data.source.path ||
|
||||||
execution.value.subject.sha256 !== result.data.source.sha256 ||
|
execution.value.subject.sha256 !== result.data.source.sha256 ||
|
||||||
execution.value.provider.id !== result.data.engine.id ||
|
execution.value.provider.id !== result.data.engine.id ||
|
||||||
@@ -85,7 +111,7 @@ export class JavaScriptRecoveryService {
|
|||||||
createEvidence(execution.value.subject, execution.value.provider, {
|
createEvidence(execution.value.subject, execution.value.provider, {
|
||||||
operation: OPERATION,
|
operation: OPERATION,
|
||||||
parameters: jsonObjectSchema.parse(parsed.data),
|
parameters: jsonObjectSchema.parse(parsed.data),
|
||||||
result: result.data,
|
result: jsonValueSchema.parse(result.data),
|
||||||
rawResult: execution.value.rawResult,
|
rawResult: execution.value.rawResult,
|
||||||
locations: execution.value.locations,
|
locations: execution.value.locations,
|
||||||
limitations: execution.value.limitations,
|
limitations: execution.value.limitations,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { access } from "node:fs/promises";
|
import { access, stat } from "node:fs/promises";
|
||||||
import { constants } from "node:fs";
|
import { constants } from "node:fs";
|
||||||
import { isAbsolute } from "node:path";
|
import { isAbsolute } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
@@ -32,6 +32,8 @@ export class MitmproxyCaptureAdapter implements HistoricalCaptureFormatAdapter {
|
|||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await access(command, constants.R_OK | constants.X_OK);
|
await access(command, constants.R_OK | constants.X_OK);
|
||||||
|
if (!(await stat(command)).isFile())
|
||||||
|
throw new TypeError("Configured path is not a regular file");
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
const reason = `Configured mitmdump executable is unavailable: ${command}.`;
|
const reason = `Configured mitmdump executable is unavailable: ${command}.`;
|
||||||
throw new AnalysisCapabilityUnavailableError(
|
throw new AnalysisCapabilityUnavailableError(
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ export const registerJavaScriptRecoveryCommands = (
|
|||||||
outputDirectory: z.string().describe("Absolute absent output directory"),
|
outputDirectory: z.string().describe("Absolute absent output directory"),
|
||||||
}),
|
}),
|
||||||
options: z.object({
|
options: z.object({
|
||||||
|
additionalPath: z
|
||||||
|
.array(z.string())
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Related absolute UTF-8 JavaScript file; repeat for multiple chunks",
|
||||||
|
),
|
||||||
extractionMode: z
|
extractionMode: z
|
||||||
.enum(["structural", "heuristic", "inspection"])
|
.enum(["structural", "heuristic", "inspection"])
|
||||||
.optional()
|
.optional()
|
||||||
@@ -51,6 +57,9 @@ export const registerJavaScriptRecoveryCommands = (
|
|||||||
{
|
{
|
||||||
path: args.path,
|
path: args.path,
|
||||||
output_directory: args.outputDirectory,
|
output_directory: args.outputDirectory,
|
||||||
|
...(options.additionalPath === undefined
|
||||||
|
? {}
|
||||||
|
: { additional_paths: options.additionalPath }),
|
||||||
...(options.extractionMode === undefined
|
...(options.extractionMode === undefined
|
||||||
? {}
|
? {}
|
||||||
: { extraction_mode: options.extractionMode }),
|
: { extraction_mode: options.extractionMode }),
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const JAVASCRIPT_RECOVERY_TOOL_CONTRACTS = [
|
|||||||
...toolContractMetadata("recover_javascript_sources"),
|
...toolContractMetadata("recover_javascript_sources"),
|
||||||
kind: "application",
|
kind: "application",
|
||||||
description:
|
description:
|
||||||
"Recover readable modules from one local UTF-8 JavaScript script/bundle into an absent directory, using caller-supplied Wakaru ^1.13.0 (verified with 1.14.0) on Linux x64. Returns original/derived digests, extraction byte ranges, emitted source maps, warnings and analysis_input inline. Structural splitting may fall back to one module; original names and runtime equivalence remain unknown. No application code execution. Requires util-linux prlimit; one worker, 1 GiB address-space ceiling, 120-second deadline, 64 MiB input, 128 MiB output, 10000 entries and 8 MiB per diagnostic/report stream.",
|
"Recover readable modules from local UTF-8 JavaScript scripts/bundles into an absent directory, using caller-supplied Wakaru ^1.13.0 (verified with 1.14.0) on Linux x64. Use additional_paths for related chunks; each input retains its own snapshot and provenance. Returns original/derived digests, extraction byte ranges, emitted source maps, warnings and analysis_input inline. Structural splitting may fall back to one module; original names and runtime equivalence remain unknown. No application code execution. Requires util-linux prlimit; one worker, 1 GiB address-space ceiling, 120-second deadline, 64 MiB total input, 128 MiB output, 10000 entries and 8 MiB per diagnostic/report stream.",
|
||||||
inputSchema: javascriptRecoveryInputSchema,
|
inputSchema: javascriptRecoveryInputSchema,
|
||||||
outputSchema: evidenceResultOf(javascriptRecoveryResultSchema),
|
outputSchema: evidenceResultOf(javascriptRecoveryResultSchema),
|
||||||
examples: [
|
examples: [
|
||||||
@@ -24,6 +24,18 @@ export const JAVASCRIPT_RECOVERY_TOOL_CONTRACTS = [
|
|||||||
output_directory: "/tmp/recovered-app",
|
output_directory: "/tmp/recovered-app",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Recover a code-split bundle together",
|
||||||
|
input: {
|
||||||
|
path: "/tmp/web-scripts/main.js",
|
||||||
|
additional_paths: [
|
||||||
|
"/tmp/web-scripts/runtime.js",
|
||||||
|
"/tmp/web-scripts/shared.js",
|
||||||
|
"/tmp/web-scripts/checkout.chunk.js",
|
||||||
|
],
|
||||||
|
output_directory: "/tmp/recovered-chunks",
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
] as const satisfies readonly ToolContract[];
|
] as const satisfies readonly ToolContract[];
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export const inspectElectronBrowserNode = (
|
|||||||
context: JavaScriptFindingContext,
|
context: JavaScriptFindingContext,
|
||||||
): void => {
|
): void => {
|
||||||
if (t.isNewExpression(node)) inspectBrowserWindow(node, context);
|
if (t.isNewExpression(node)) inspectBrowserWindow(node, context);
|
||||||
if (!t.isCallExpression(node)) return;
|
if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) return;
|
||||||
inspectContextBridge(node, context);
|
inspectContextBridge(node, context);
|
||||||
inspectUtilityProcess(node, context);
|
inspectUtilityProcess(node, context);
|
||||||
};
|
};
|
||||||
@@ -162,7 +162,7 @@ const collectWebPreferences = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const inspectContextBridge = (
|
const inspectContextBridge = (
|
||||||
node: t.CallExpression,
|
node: t.CallExpression | t.OptionalCallExpression,
|
||||||
context: JavaScriptFindingContext,
|
context: JavaScriptFindingContext,
|
||||||
): void => {
|
): void => {
|
||||||
const path = electronCalleePath(node.callee, context);
|
const path = electronCalleePath(node.callee, context);
|
||||||
@@ -208,7 +208,7 @@ const inspectContextBridge = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const inspectUtilityProcess = (
|
const inspectUtilityProcess = (
|
||||||
node: t.CallExpression,
|
node: t.CallExpression | t.OptionalCallExpression,
|
||||||
context: JavaScriptFindingContext,
|
context: JavaScriptFindingContext,
|
||||||
): void => {
|
): void => {
|
||||||
const path = electronCalleePath(node.callee, context);
|
const path = electronCalleePath(node.callee, context);
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
|
||||||
|
|
||||||
|
describe("Electron API identity", () => {
|
||||||
|
it("recovers APIs through an ES module default import of Electron", () => {
|
||||||
|
const analysis = analyzeJavaScriptStaticSource(`
|
||||||
|
import electron from "electron";
|
||||||
|
const { ipcMain } = electron;
|
||||||
|
new electron.BrowserWindow({ webPreferences: { preload: "./preload.js" } });
|
||||||
|
electron.contextBridge.exposeInMainWorld("api", { ping: true });
|
||||||
|
electron.ipcRenderer.invoke("default:ping");
|
||||||
|
ipcMain.handle("destructured:ping", () => "pong");
|
||||||
|
electron.default.ipcMain.handle("nested-default", () => "local");
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(analysis.electron.browser_windows).toMatchObject([
|
||||||
|
{ preload_path: "./preload.js" },
|
||||||
|
]);
|
||||||
|
expect(analysis.electron.context_bridge_apis).toMatchObject([
|
||||||
|
{ api_key: "api", members: ["ping"] },
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
analysis.electron.ipc.map(({ side, operation, channel }) => ({
|
||||||
|
side,
|
||||||
|
operation,
|
||||||
|
channel,
|
||||||
|
})),
|
||||||
|
).toEqual([
|
||||||
|
{ side: "renderer", operation: "invoke", channel: "default:ping" },
|
||||||
|
{ side: "main", operation: "handle", channel: "destructured:ping" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recovers APIs and sender checks written with optional chaining", () => {
|
||||||
|
const analysis = analyzeJavaScriptStaticSource(`
|
||||||
|
const { contextBridge, ipcMain, utilityProcess } = require("electron");
|
||||||
|
contextBridge?.exposeInMainWorld("api", {});
|
||||||
|
utilityProcess?.fork("./worker.js");
|
||||||
|
ipcMain?.handle("optional:ping", (event) => {
|
||||||
|
if (!event.senderFrame?.url.startsWith("file://")) return null;
|
||||||
|
return event.sender?.getURL() === "file:///index.html";
|
||||||
|
});
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(analysis.electron.context_bridge_apis).toMatchObject([
|
||||||
|
{ api_key: "api" },
|
||||||
|
]);
|
||||||
|
expect(analysis.electron.utility_processes).toMatchObject([
|
||||||
|
{ module_path: "./worker.js" },
|
||||||
|
]);
|
||||||
|
expect(analysis.electron.ipc).toMatchObject([
|
||||||
|
{ side: "main", operation: "handle", channel: "optional:ping" },
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
analysis.electron.sender_validations.map(
|
||||||
|
({ subject, mechanism, expected }) => ({
|
||||||
|
subject,
|
||||||
|
mechanism,
|
||||||
|
expected: expected.value,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
subject: "sender-url",
|
||||||
|
mechanism: "call:startsWith",
|
||||||
|
expected: "file://",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
subject: "sender-url",
|
||||||
|
mechanism: "comparison:===",
|
||||||
|
expected: "file:///index.html",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,7 +26,7 @@ export const inspectElectronIpcNode = (
|
|||||||
node: t.Node,
|
node: t.Node,
|
||||||
context: JavaScriptFindingContext,
|
context: JavaScriptFindingContext,
|
||||||
): void => {
|
): void => {
|
||||||
if (t.isCallExpression(node)) {
|
if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) {
|
||||||
inspectIpcCall(node, context);
|
inspectIpcCall(node, context);
|
||||||
inspectValidationCall(node, context);
|
inspectValidationCall(node, context);
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,7 @@ export const inspectElectronIpcNode = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const inspectIpcCall = (
|
const inspectIpcCall = (
|
||||||
node: t.CallExpression,
|
node: t.CallExpression | t.OptionalCallExpression,
|
||||||
context: JavaScriptFindingContext,
|
context: JavaScriptFindingContext,
|
||||||
): void => {
|
): void => {
|
||||||
const descriptor = ipcDescriptor(electronCalleePath(node.callee, context));
|
const descriptor = ipcDescriptor(electronCalleePath(node.callee, context));
|
||||||
@@ -117,7 +117,7 @@ const inspectValidationComparison = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const inspectValidationCall = (
|
const inspectValidationCall = (
|
||||||
node: t.CallExpression,
|
node: t.CallExpression | t.OptionalCallExpression,
|
||||||
context: JavaScriptFindingContext,
|
context: JavaScriptFindingContext,
|
||||||
): void => {
|
): void => {
|
||||||
if (
|
if (
|
||||||
@@ -225,9 +225,10 @@ const ipcDescriptor = (
|
|||||||
const senderSubject = (
|
const senderSubject = (
|
||||||
node: t.Node,
|
node: t.Node,
|
||||||
): ElectronSenderValidationFinding["subject"] | undefined => {
|
): ElectronSenderValidationFinding["subject"] | undefined => {
|
||||||
const name = t.isCallExpression(node)
|
const name =
|
||||||
? calleeName(node.callee)
|
t.isCallExpression(node) || t.isOptionalCallExpression(node)
|
||||||
: calleeName(node);
|
? calleeName(node.callee)
|
||||||
|
: calleeName(node);
|
||||||
if (/(?:^|\.)sender\.getURL$/u.test(name)) return "sender-url";
|
if (/(?:^|\.)sender\.getURL$/u.test(name)) return "sender-url";
|
||||||
if (/(?:^|\.)senderFrame\.origin$/u.test(name)) return "sender-origin";
|
if (/(?:^|\.)senderFrame\.origin$/u.test(name)) return "sender-origin";
|
||||||
if (/(?:^|\.)senderFrame\.url$/u.test(name)) return "sender-url";
|
if (/(?:^|\.)senderFrame\.url$/u.test(name)) return "sender-url";
|
||||||
|
|||||||
@@ -12,6 +12,16 @@ import { traverseJavaScriptAstSteps } from "./javascriptSemanticTraversal.js";
|
|||||||
export const ELECTRON_MODULE =
|
export const ELECTRON_MODULE =
|
||||||
/^electron(?:\/(?:common|main|renderer|utility))?$/u;
|
/^electron(?:\/(?:common|main|renderer|utility))?$/u;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Electron's CommonJS exports define no `default` member, so a leading
|
||||||
|
* `default` segment can only denote the module object itself, as an ES module
|
||||||
|
* default import (`import electron from "electron"`) binds it.
|
||||||
|
*/
|
||||||
|
export const electronExportPath = (
|
||||||
|
importedPath: readonly string[],
|
||||||
|
): readonly string[] =>
|
||||||
|
importedPath[0] === "default" ? importedPath.slice(1) : importedPath;
|
||||||
|
|
||||||
/** Static identity recovery cannot establish the absence of Electron usage. */
|
/** Static identity recovery cannot establish the absence of Electron usage. */
|
||||||
export const ELECTRON_IDENTITY_LIMITATION =
|
export const ELECTRON_IDENTITY_LIMITATION =
|
||||||
"Electron API identity follows proven lexical module aliases. Reassigned bindings, dynamic selections, and visibly overwritten namespace members are unresolved; missing findings do not establish absence. Arbitrary call side effects and runtime registration are not evaluated.";
|
"Electron API identity follows proven lexical module aliases. Reassigned bindings, dynamic selections, and visibly overwritten namespace members are unresolved; missing findings do not establish absence. Arbitrary call side effects and runtime registration are not evaluated.";
|
||||||
|
|||||||
@@ -3,11 +3,18 @@ import { digestSchema } from "../digests.js";
|
|||||||
import { analyzeJavaScriptApplicationInputSchema } from "./javascriptApplicationAnalysis.js";
|
import { analyzeJavaScriptApplicationInputSchema } from "./javascriptApplicationAnalysis.js";
|
||||||
import { localPathStringSchema } from "../localPath.js";
|
import { localPathStringSchema } from "../localPath.js";
|
||||||
|
|
||||||
/** Recover derived readable modules from one explicitly selected local script. */
|
/** Recover derived readable modules from explicitly selected local scripts. */
|
||||||
export const javascriptRecoveryInputSchema = z.strictObject({
|
export const javascriptRecoveryInputSchema = z.strictObject({
|
||||||
path: localPathStringSchema.describe(
|
path: localPathStringSchema.describe(
|
||||||
"Absolute path to a UTF-8 JavaScript file; never executed",
|
"Absolute path to a UTF-8 JavaScript file; never executed",
|
||||||
),
|
),
|
||||||
|
additional_paths: z
|
||||||
|
.array(localPathStringSchema)
|
||||||
|
.max(16)
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Additional absolute UTF-8 JavaScript files recovered together with path; each is snapshotted separately so imports can resolve across related chunks. All inputs share a 64 MiB total budget",
|
||||||
|
),
|
||||||
output_directory: localPathStringSchema.describe(
|
output_directory: localPathStringSchema.describe(
|
||||||
"Absolute absent output directory; parent must exist",
|
"Absolute absent output directory; parent must exist",
|
||||||
),
|
),
|
||||||
@@ -36,12 +43,15 @@ const rangeSchema = z.strictObject({
|
|||||||
end: z.number().int().nonnegative(),
|
end: z.number().int().nonnegative(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const sourceSchema = fileSchema.extend({
|
||||||
|
snapshot_path: z.string().min(1),
|
||||||
|
published_copy: fileSchema,
|
||||||
|
});
|
||||||
|
|
||||||
/** Complete published artifacts and exact producer provenance for source recovery. */
|
/** Complete published artifacts and exact producer provenance for source recovery. */
|
||||||
export const javascriptRecoveryResultSchema = z.strictObject({
|
export const javascriptRecoveryResultSchema = z.strictObject({
|
||||||
source: fileSchema.extend({
|
source: sourceSchema,
|
||||||
snapshot_path: z.string().min(1),
|
additional_sources: z.array(sourceSchema).optional(),
|
||||||
published_copy: fileSchema,
|
|
||||||
}),
|
|
||||||
engine: z.strictObject({
|
engine: z.strictObject({
|
||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
version: z.string().min(1),
|
version: z.string().min(1),
|
||||||
|
|||||||
@@ -0,0 +1,329 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
|
||||||
|
|
||||||
|
const endpoints = (source: string) =>
|
||||||
|
analyzeJavaScriptStaticSource(source).endpoints.map(
|
||||||
|
({ kind, value, mechanism }) => ({ kind, value, mechanism }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const rpcEndpoints = (source: string) =>
|
||||||
|
endpoints(source).filter(
|
||||||
|
({ mechanism }) =>
|
||||||
|
mechanism.startsWith("trpc:") || mechanism.startsWith("graphql:"),
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("tRPC procedure recognition", () => {
|
||||||
|
it("recognizes React hook procedures on a renamed proxy root", () => {
|
||||||
|
expect(
|
||||||
|
rpcEndpoints(`
|
||||||
|
const a = n.post.byId.useQuery({ id: 1 });
|
||||||
|
const b = n.post.add.useMutation();
|
||||||
|
n.feed.useInfiniteQuery({ limit: 10 });
|
||||||
|
n.onEvent.useSubscription(undefined, { onData() {} });
|
||||||
|
n.invoice.list.useSuspenseQuery();
|
||||||
|
`),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
kind: "network",
|
||||||
|
value: "post.byId",
|
||||||
|
mechanism: "trpc:procedure:useQuery",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "network",
|
||||||
|
value: "post.add",
|
||||||
|
mechanism: "trpc:procedure:useMutation",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "network",
|
||||||
|
value: "feed",
|
||||||
|
mechanism: "trpc:procedure:useInfiniteQuery",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "network",
|
||||||
|
value: "onEvent",
|
||||||
|
mechanism: "trpc:procedure:useSubscription",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "network",
|
||||||
|
value: "invoice.list",
|
||||||
|
mechanism: "trpc:procedure:useSuspenseQuery",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes TanStack React Query integration option factories", () => {
|
||||||
|
expect(
|
||||||
|
rpcEndpoints(`
|
||||||
|
useQuery(e.invoice.list.queryOptions({ page: 1 }));
|
||||||
|
useMutation(e.invoice.create.mutationOptions());
|
||||||
|
client.invalidateQueries({ queryKey: e.invoice.list.queryKey() });
|
||||||
|
`),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
kind: "network",
|
||||||
|
value: "invoice.list",
|
||||||
|
mechanism: "trpc:procedure:queryOptions",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "network",
|
||||||
|
value: "invoice.create",
|
||||||
|
mechanism: "trpc:procedure:mutationOptions",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes vanilla client calls and link URLs only in sources carrying a tRPC marker", () => {
|
||||||
|
expect(
|
||||||
|
rpcEndpoints(`
|
||||||
|
const client = createTRPCClient({
|
||||||
|
links: [httpBatchLink({ url: "/api/trpc" })],
|
||||||
|
});
|
||||||
|
const bilbo = await client.getUser.query("id_bilbo");
|
||||||
|
await client.post.add.mutate({ title: "x" });
|
||||||
|
client.onPost.subscribe(undefined, { onData() {} });
|
||||||
|
`),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
kind: "network",
|
||||||
|
value: "/api/trpc",
|
||||||
|
mechanism: "trpc:link:httpBatchLink",
|
||||||
|
},
|
||||||
|
{ kind: "network", value: "getUser", mechanism: "trpc:procedure:query" },
|
||||||
|
{
|
||||||
|
kind: "network",
|
||||||
|
value: "post.add",
|
||||||
|
mechanism: "trpc:procedure:mutate",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "network",
|
||||||
|
value: "onPost",
|
||||||
|
mechanism: "trpc:procedure:subscribe",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
rpcEndpoints(`
|
||||||
|
const rows = await pool.users.query("select 1");
|
||||||
|
emitter.changes.subscribe(listener);
|
||||||
|
store.cart.mutate(update);
|
||||||
|
`),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires corroboration for request methods shared with Apollo, RxJS and stores", () => {
|
||||||
|
// The source carries a tRPC marker, so only the call shape can decide.
|
||||||
|
expect(
|
||||||
|
rpcEndpoints(`
|
||||||
|
class TRPCClientError extends Error {}
|
||||||
|
this.client.query({ query: q });
|
||||||
|
e.queryManager.query(options);
|
||||||
|
this.untypedClient.query("path", input);
|
||||||
|
this.route.params.subscribe((params) => params);
|
||||||
|
t.current.subscribe(listener);
|
||||||
|
r.store.subscribe(() => {});
|
||||||
|
n.cache.mutate(update);
|
||||||
|
o.k.endpoints.getPosts.useQuery();
|
||||||
|
e.api.endpoints.list.useQuery();
|
||||||
|
this.trpc.invoice.list.useQuery();
|
||||||
|
`),
|
||||||
|
).toEqual([]);
|
||||||
|
expect(
|
||||||
|
rpcEndpoints(`
|
||||||
|
class TRPCClientError extends Error {}
|
||||||
|
e.invoice.list.query({ page: 1 });
|
||||||
|
e.invoice.onChange.subscribe(undefined, { onData() {} });
|
||||||
|
`),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
kind: "network",
|
||||||
|
value: "invoice.list",
|
||||||
|
mechanism: "trpc:procedure:query",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "network",
|
||||||
|
value: "invoice.onChange",
|
||||||
|
mechanism: "trpc:procedure:subscribe",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never reports browser globals or RTK Query endpoints as procedures", () => {
|
||||||
|
expect(
|
||||||
|
rpcEndpoints(`
|
||||||
|
class TRPCClientError extends Error {}
|
||||||
|
navigator.permissions.query({ name: "geolocation" });
|
||||||
|
window.navigator.permissions.query({ name: "camera" });
|
||||||
|
api.endpoints.getPosts.useQuery();
|
||||||
|
api.useGetPostsQuery();
|
||||||
|
useQuery({ queryKey: ["x"] });
|
||||||
|
`),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("tRPC binding identity and bundled syntax", () => {
|
||||||
|
it("keeps client bindings local to their lexical declaration", () => {
|
||||||
|
expect(
|
||||||
|
rpcEndpoints(`
|
||||||
|
function first() { const c = createTRPCClient({}); c.users.query(); }
|
||||||
|
function unrelated(c) { c.users.query("select 1"); }
|
||||||
|
const c = createTRPCClient({});
|
||||||
|
function closure() { c.posts.query(); }
|
||||||
|
function shadowed() { c.users.query(); var c = pool; }
|
||||||
|
{ const c = pool; c.users.query(); }
|
||||||
|
try {} catch (c) { c.users.query(); }
|
||||||
|
`),
|
||||||
|
).toEqual([
|
||||||
|
{ kind: "network", value: "users", mechanism: "trpc:procedure:query" },
|
||||||
|
{ kind: "network", value: "posts", mechanism: "trpc:procedure:query" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not corroborate overwritten or destructured client bindings", () => {
|
||||||
|
expect(
|
||||||
|
rpcEndpoints(`
|
||||||
|
let c = createTRPCClient({}); c = pool; c.users.query();
|
||||||
|
const { client } = createTRPCClient({}); client.users.query();
|
||||||
|
`),
|
||||||
|
).toEqual([]);
|
||||||
|
expect(
|
||||||
|
rpcEndpoints(`
|
||||||
|
var c = createTRPCClient({}); c.users.query();
|
||||||
|
`),
|
||||||
|
).toEqual([
|
||||||
|
{ kind: "network", value: "users", mechanism: "trpc:procedure:query" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes surviving factory names inside bundled call wrappers", () => {
|
||||||
|
expect(
|
||||||
|
rpcEndpoints(`
|
||||||
|
const c = (0, t.createTRPCClient)({
|
||||||
|
links: [(0, t.httpBatchLink)({ url: "/api/rpc" })],
|
||||||
|
});
|
||||||
|
c.users.query();
|
||||||
|
const p = (t.createTRPCProxyClient as any)({});
|
||||||
|
p.posts.query();
|
||||||
|
(0, t.httpLink)({ url: "/api/other" });
|
||||||
|
`),
|
||||||
|
).toEqual([
|
||||||
|
{
|
||||||
|
kind: "network",
|
||||||
|
value: "/api/rpc",
|
||||||
|
mechanism: "trpc:link:httpBatchLink",
|
||||||
|
},
|
||||||
|
{ kind: "network", value: "users", mechanism: "trpc:procedure:query" },
|
||||||
|
{ kind: "network", value: "posts", mechanism: "trpc:procedure:query" },
|
||||||
|
{ kind: "network", value: "/api/other", mechanism: "trpc:link:httpLink" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves procedure paths beyond the former nesting cutoff", () => {
|
||||||
|
const path = Array.from({ length: 64 }, (_, i) => `router${i}`).join(".");
|
||||||
|
expect(rpcEndpoints(`root.${path}.useQuery();`)).toEqual([
|
||||||
|
{ kind: "network", value: path, mechanism: "trpc:procedure:useQuery" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GraphQL operation recognition", () => {
|
||||||
|
it("recognizes named operations in tagged templates and string literals", () => {
|
||||||
|
expect(
|
||||||
|
rpcEndpoints(`
|
||||||
|
const GET_USER = gql\`
|
||||||
|
# comment
|
||||||
|
query GetUser($id: ID!) { user(id: $id) { name } }
|
||||||
|
\`;
|
||||||
|
const UPDATE = "mutation UpdateInvoice($input: InvoiceInput!) { updateInvoice(input: $input) { id } }";
|
||||||
|
const ON = \`subscription OnInvoice { invoiceChanged { id } } \${fragment}\`;
|
||||||
|
`),
|
||||||
|
).toEqual([
|
||||||
|
{ kind: "network", value: "GetUser", mechanism: "graphql:query" },
|
||||||
|
{
|
||||||
|
kind: "network",
|
||||||
|
value: "UpdateInvoice",
|
||||||
|
mechanism: "graphql:mutation",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "network",
|
||||||
|
value: "OnInvoice",
|
||||||
|
mechanism: "graphql:subscription",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains operations after long ignored prefixes and long headers", () => {
|
||||||
|
const name = "Operation" + "x".repeat(512);
|
||||||
|
const text =
|
||||||
|
"#" +
|
||||||
|
"comment".repeat(700) +
|
||||||
|
"\nquery " +
|
||||||
|
name +
|
||||||
|
" ".repeat(600) +
|
||||||
|
"{ users { id } }";
|
||||||
|
expect(rpcEndpoints(`const doc = ${JSON.stringify(text)};`)).toEqual([
|
||||||
|
{ kind: "network", value: name, mechanism: "graphql:query" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes precompiled GraphQL document objects", () => {
|
||||||
|
expect(
|
||||||
|
rpcEndpoints(`
|
||||||
|
const doc = { kind: "Document", definitions: [{
|
||||||
|
kind: "OperationDefinition",
|
||||||
|
operation: "query",
|
||||||
|
name: { kind: "Name", value: "ListInvoices" },
|
||||||
|
selectionSet: { kind: "SelectionSet", selections: [] },
|
||||||
|
}] };
|
||||||
|
`),
|
||||||
|
).toEqual([
|
||||||
|
{ kind: "network", value: "ListInvoices", mechanism: "graphql:query" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores anonymous operations and ordinary prose", () => {
|
||||||
|
expect(
|
||||||
|
rpcEndpoints(`
|
||||||
|
const a = "query { viewer { id } }";
|
||||||
|
const b = "query string parameters";
|
||||||
|
const c = "mutation observer started";
|
||||||
|
const d = { kind: "OperationDefinition", operation: "query" };
|
||||||
|
const e = "mutation Observer {";
|
||||||
|
const f = "query Search(";
|
||||||
|
const g = "subscription Plan (monthly)";
|
||||||
|
`),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a precompiled graphql-tag document once, not again from loc.source.body", () => {
|
||||||
|
expect(
|
||||||
|
rpcEndpoints(`
|
||||||
|
const doc = { kind: "Document", definitions: [{
|
||||||
|
kind: "OperationDefinition",
|
||||||
|
operation: "query",
|
||||||
|
name: { kind: "Name", value: "GetUser" },
|
||||||
|
selectionSet: { kind: "SelectionSet", selections: [] },
|
||||||
|
}], loc: { start: 0, end: 40, source: {
|
||||||
|
body: "query GetUser($id: ID!) { user(id: $id) { name } }",
|
||||||
|
name: "GraphQL request",
|
||||||
|
locationOffset: { line: 1, column: 1 },
|
||||||
|
} } };
|
||||||
|
`),
|
||||||
|
).toEqual([
|
||||||
|
{ kind: "network", value: "GetUser", mechanism: "graphql:query" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps existing network endpoint recognition unchanged", () => {
|
||||||
|
expect(
|
||||||
|
endpoints(`fetch("/api/invoices"); n.invoice.list.useQuery();`),
|
||||||
|
).toEqual([
|
||||||
|
{ kind: "network", value: "/api/invoices", mechanism: "call:fetch" },
|
||||||
|
{
|
||||||
|
kind: "network",
|
||||||
|
value: "invoice.list",
|
||||||
|
mechanism: "trpc:procedure:useQuery",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
import * as t from "@babel/types";
|
||||||
|
|
||||||
|
import {
|
||||||
|
unwrapJavaScriptExpression,
|
||||||
|
semanticStaticPropertyKey,
|
||||||
|
} from "./javascriptAstValues.js";
|
||||||
|
import { calleeName } from "./javascriptStaticAnalysisHelpers.js";
|
||||||
|
|
||||||
|
/** One RPC operation or transport URL recovered from client-side syntax. */
|
||||||
|
export interface JavaScriptRpcEndpoint {
|
||||||
|
readonly value: string;
|
||||||
|
readonly mechanism: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-source RPC recognition state; feed it every AST node in pre-order. */
|
||||||
|
export interface JavaScriptRpcScan {
|
||||||
|
inspect(node: t.Node): JavaScriptRpcEndpoint | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// tRPC proxies preserve router and procedure names as property names, so they
|
||||||
|
// survive identifier minification. Method names follow @trpc/react-query and
|
||||||
|
// @trpc/tanstack-react-query 11.19 documentation; cache-key helpers such as
|
||||||
|
// queryKey are excluded because they never issue a request.
|
||||||
|
const TRPC_HOOK_METHODS: ReadonlySet<string> = new Set([
|
||||||
|
"useQuery",
|
||||||
|
"useSuspenseQuery",
|
||||||
|
"useInfiniteQuery",
|
||||||
|
"useSuspenseInfiniteQuery",
|
||||||
|
"usePrefetchQuery",
|
||||||
|
"usePrefetchInfiniteQuery",
|
||||||
|
"useMutation",
|
||||||
|
"useSubscription",
|
||||||
|
"queryOptions",
|
||||||
|
"infiniteQueryOptions",
|
||||||
|
"mutationOptions",
|
||||||
|
"subscriptionOptions",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// @trpc/client 11.19 clientCallTypeMap methods; common outside tRPC (Apollo,
|
||||||
|
// database pools, stores), so they need corroborating evidence.
|
||||||
|
const TRPC_REQUEST_METHODS: ReadonlySet<string> = new Set(["query", "mutate"]);
|
||||||
|
// @trpc/client 11.19 TRPCSubscriptionObserver callbacks; RxJS observers use
|
||||||
|
// next/error/complete instead.
|
||||||
|
const TRPC_SUBSCRIPTION_CALLBACKS: ReadonlySet<string> = new Set([
|
||||||
|
"onStarted",
|
||||||
|
"onData",
|
||||||
|
"onError",
|
||||||
|
"onStopped",
|
||||||
|
"onComplete",
|
||||||
|
"onConnectionStateChange",
|
||||||
|
]);
|
||||||
|
// @trpc/client 11.19 client factories (createTRPCProxyClient is the
|
||||||
|
// deprecated alias of createTRPCClient).
|
||||||
|
const TRPC_CLIENT_FACTORIES: ReadonlySet<string> = new Set([
|
||||||
|
"createTRPCClient",
|
||||||
|
"createTRPCProxyClient",
|
||||||
|
]);
|
||||||
|
// Built-in HTTP links documented by tRPC 11.19 that carry a `url` option.
|
||||||
|
const TRPC_URL_LINKS: ReadonlySet<string> = new Set([
|
||||||
|
"httpLink",
|
||||||
|
"httpBatchLink",
|
||||||
|
"httpBatchStreamLink",
|
||||||
|
"httpSubscriptionLink",
|
||||||
|
]);
|
||||||
|
// Literals retained by @trpc/client 11.19 bundles (`this.name`, the default
|
||||||
|
// accept header key) or by the conventional transport path.
|
||||||
|
const TRPC_SOURCE_MARKERS = ["TRPCClientError", "trpc-accept", "/trpc"];
|
||||||
|
|
||||||
|
// Browser-owned roots whose methods share tRPC names, such as
|
||||||
|
// navigator.permissions.query.
|
||||||
|
const BROWSER_GLOBAL_NAMES: ReadonlySet<string> = new Set([
|
||||||
|
"window",
|
||||||
|
"self",
|
||||||
|
"globalThis",
|
||||||
|
"document",
|
||||||
|
"navigator",
|
||||||
|
"location",
|
||||||
|
"history",
|
||||||
|
"console",
|
||||||
|
"performance",
|
||||||
|
"screen",
|
||||||
|
"top",
|
||||||
|
"parent",
|
||||||
|
"frames",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const PROCEDURE_SEGMENT = /^[A-Za-z_$][\w$]*$/u;
|
||||||
|
|
||||||
|
interface MemberPath {
|
||||||
|
readonly root: t.Node;
|
||||||
|
/** Non-computed property names from the root outward. */
|
||||||
|
readonly properties: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a static member path and the expression it starts from. */
|
||||||
|
const memberPath = (callee: t.Node): MemberPath | undefined => {
|
||||||
|
const reversed: string[] = [];
|
||||||
|
let current: t.Node = callee;
|
||||||
|
while (
|
||||||
|
t.isMemberExpression(current) ||
|
||||||
|
t.isOptionalMemberExpression(current)
|
||||||
|
) {
|
||||||
|
const property = semanticStaticPropertyKey(
|
||||||
|
current.property,
|
||||||
|
current.computed,
|
||||||
|
);
|
||||||
|
if (property === null || !PROCEDURE_SEGMENT.test(property))
|
||||||
|
return undefined;
|
||||||
|
reversed.push(property);
|
||||||
|
current = current.object;
|
||||||
|
}
|
||||||
|
return { root: current, properties: reversed.reverse() };
|
||||||
|
};
|
||||||
|
|
||||||
|
const objectKeys = (node: t.Node | undefined): readonly string[] =>
|
||||||
|
t.isObjectExpression(node)
|
||||||
|
? node.properties.flatMap((property) => {
|
||||||
|
if (!t.isObjectProperty(property) && !t.isObjectMethod(property))
|
||||||
|
return [];
|
||||||
|
const key = semanticStaticPropertyKey(property.key, property.computed);
|
||||||
|
return key === null ? [] : [key];
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const objectStringProperty = (
|
||||||
|
node: t.ObjectExpression,
|
||||||
|
key: string,
|
||||||
|
): string | undefined => {
|
||||||
|
for (const property of node.properties)
|
||||||
|
if (
|
||||||
|
t.isObjectProperty(property) &&
|
||||||
|
semanticStaticPropertyKey(property.key, property.computed) === key &&
|
||||||
|
t.isStringLiteral(property.value)
|
||||||
|
)
|
||||||
|
return property.value.value;
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const objectProperty = (
|
||||||
|
node: t.ObjectExpression,
|
||||||
|
key: string,
|
||||||
|
): t.Node | undefined => {
|
||||||
|
for (const property of node.properties)
|
||||||
|
if (
|
||||||
|
t.isObjectProperty(property) &&
|
||||||
|
semanticStaticPropertyKey(property.key, property.computed) === key
|
||||||
|
)
|
||||||
|
return property.value;
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const GRAPHQL_OPERATION_TYPES: ReadonlySet<string> = new Set([
|
||||||
|
"query",
|
||||||
|
"mutation",
|
||||||
|
"subscription",
|
||||||
|
]);
|
||||||
|
// A named GraphQL operation header (GraphQL October 2021, OperationDefinition):
|
||||||
|
// type, name, then variable definitions starting with `$`, a directive, or a
|
||||||
|
// selection set starting with a field name.
|
||||||
|
const GRAPHQL_OPERATION_HEADER =
|
||||||
|
/^(query|mutation|subscription)[\s,]+([_A-Za-z][_0-9A-Za-z]*)[\s,]*(?:\([\s,]*\$|@[_A-Za-z]|\{[\s,]*[_A-Za-z])/u;
|
||||||
|
|
||||||
|
const isGraphqlIgnoredCharacter = (character: string): boolean =>
|
||||||
|
character === "," || character === "" || /\s/u.test(character);
|
||||||
|
|
||||||
|
/** Recover a named operation from GraphQL document text. */
|
||||||
|
export const graphqlOperationFromText = (
|
||||||
|
text: string,
|
||||||
|
): JavaScriptRpcEndpoint | undefined => {
|
||||||
|
// Skip GraphQL ignored tokens: whitespace, commas, BOM and # comments.
|
||||||
|
const limit = text.length;
|
||||||
|
let index = 0;
|
||||||
|
while (index < limit) {
|
||||||
|
const character = text.charAt(index);
|
||||||
|
if (isGraphqlIgnoredCharacter(character)) index += 1;
|
||||||
|
else if (character === "#") {
|
||||||
|
while (index < limit && !/[\n\r]/u.test(text.charAt(index))) index += 1;
|
||||||
|
} else break;
|
||||||
|
}
|
||||||
|
if (index >= limit) return undefined;
|
||||||
|
const header = GRAPHQL_OPERATION_HEADER.exec(text.slice(index));
|
||||||
|
const type = header?.[1];
|
||||||
|
const name = header?.[2];
|
||||||
|
return type === undefined || name === undefined
|
||||||
|
? undefined
|
||||||
|
: { value: name, mechanism: `graphql:${type}` };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Recover a named operation from a precompiled GraphQL document object. */
|
||||||
|
export const graphqlOperationFromDocumentNode = (
|
||||||
|
node: t.ObjectExpression,
|
||||||
|
): JavaScriptRpcEndpoint | undefined => {
|
||||||
|
if (objectStringProperty(node, "kind") !== "OperationDefinition")
|
||||||
|
return undefined;
|
||||||
|
const type = objectStringProperty(node, "operation");
|
||||||
|
if (type === undefined || !GRAPHQL_OPERATION_TYPES.has(type))
|
||||||
|
return undefined;
|
||||||
|
const name = objectProperty(node, "name");
|
||||||
|
if (
|
||||||
|
!t.isObjectExpression(name) ||
|
||||||
|
objectStringProperty(name, "kind") !== "Name"
|
||||||
|
)
|
||||||
|
return undefined;
|
||||||
|
const value = objectStringProperty(name, "value");
|
||||||
|
return value === undefined
|
||||||
|
? undefined
|
||||||
|
: { value, mechanism: `graphql:${type}` };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Bundlers use `(0, imported.factory)` to call an import without a receiver. */
|
||||||
|
const rpcFactoryName = (callee: t.Node): string => {
|
||||||
|
let current = unwrapJavaScriptExpression(callee).node;
|
||||||
|
while (t.isSequenceExpression(current)) {
|
||||||
|
const last = current.expressions.at(-1);
|
||||||
|
if (last === undefined) return "";
|
||||||
|
current = unwrapJavaScriptExpression(last).node;
|
||||||
|
}
|
||||||
|
return calleeName(current);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Whether an initializer has the syntax of a documented tRPC client factory. */
|
||||||
|
export const isJavaScriptRpcClientFactory = (node: t.Node): boolean => {
|
||||||
|
const call = unwrapJavaScriptExpression(node).node;
|
||||||
|
return (
|
||||||
|
(t.isCallExpression(call) || t.isOptionalCallExpression(call)) &&
|
||||||
|
TRPC_CLIENT_FACTORIES.has(
|
||||||
|
rpcFactoryName(call.callee).split(".").pop() ?? "",
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Recover the literal `url` option passed to a documented tRPC HTTP link. */
|
||||||
|
const trpcLinkUrl = (
|
||||||
|
node: t.CallExpression | t.OptionalCallExpression,
|
||||||
|
): JavaScriptRpcEndpoint | undefined => {
|
||||||
|
const name = rpcFactoryName(node.callee);
|
||||||
|
const factory = name.slice(name.lastIndexOf(".") + 1);
|
||||||
|
if (!TRPC_URL_LINKS.has(factory)) return undefined;
|
||||||
|
const options = node.arguments[0];
|
||||||
|
if (!t.isObjectExpression(options)) return undefined;
|
||||||
|
const url = objectStringProperty(options, "url");
|
||||||
|
return url === undefined
|
||||||
|
? undefined
|
||||||
|
: { value: url, mechanism: `trpc:link:${factory}` };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the per-source scan. Hook and option-factory methods are distinctive.
|
||||||
|
* `query`/`mutate` need a root bound to a tRPC client factory, or a tRPC source
|
||||||
|
* marker with a router path of at least two segments; `subscribe` needs the
|
||||||
|
* marker and a tRPC observer argument. `this` roots, browser globals and RTK
|
||||||
|
* Query `endpoints` chains are never procedures.
|
||||||
|
*/
|
||||||
|
export const createJavaScriptRpcScan = (
|
||||||
|
source: string,
|
||||||
|
clientRoots: ReadonlySet<t.Node>,
|
||||||
|
): JavaScriptRpcScan => {
|
||||||
|
let marker: boolean | undefined;
|
||||||
|
const hasMarker = (): boolean =>
|
||||||
|
(marker ??= TRPC_SOURCE_MARKERS.some((value) => source.includes(value)));
|
||||||
|
// graphql-tag documents retain their source text in loc.source.body; that
|
||||||
|
// text repeats the OperationDefinition already recovered from the object.
|
||||||
|
const documentSourceBodies = new WeakSet<t.Node>();
|
||||||
|
|
||||||
|
const procedure = (
|
||||||
|
node: t.CallExpression | t.OptionalCallExpression,
|
||||||
|
): JavaScriptRpcEndpoint | undefined => {
|
||||||
|
const path = memberPath(node.callee);
|
||||||
|
if (path === undefined || t.isThisExpression(path.root)) return undefined;
|
||||||
|
if (t.isIdentifier(path.root) && BROWSER_GLOBAL_NAMES.has(path.root.name))
|
||||||
|
return undefined;
|
||||||
|
const method = path.properties[path.properties.length - 1];
|
||||||
|
const segments = path.properties.slice(0, -1);
|
||||||
|
const first = segments[0];
|
||||||
|
if (method === undefined || first === undefined) return undefined;
|
||||||
|
if (BROWSER_GLOBAL_NAMES.has(first) || segments.includes("endpoints"))
|
||||||
|
return undefined;
|
||||||
|
const bound = clientRoots.has(path.root);
|
||||||
|
const recognized = TRPC_HOOK_METHODS.has(method)
|
||||||
|
? true
|
||||||
|
: TRPC_REQUEST_METHODS.has(method)
|
||||||
|
? bound || (segments.length >= 2 && hasMarker())
|
||||||
|
: method === "subscribe" &&
|
||||||
|
(bound || hasMarker()) &&
|
||||||
|
objectKeys(node.arguments[1]).some((key) =>
|
||||||
|
TRPC_SUBSCRIPTION_CALLBACKS.has(key),
|
||||||
|
);
|
||||||
|
return recognized
|
||||||
|
? { value: segments.join("."), mechanism: `trpc:procedure:${method}` }
|
||||||
|
: undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
inspect: (node) => {
|
||||||
|
if (t.isCallExpression(node) || t.isOptionalCallExpression(node))
|
||||||
|
return trpcLinkUrl(node) ?? procedure(node);
|
||||||
|
if (t.isObjectExpression(node)) {
|
||||||
|
const body = objectProperty(node, "body");
|
||||||
|
if (
|
||||||
|
t.isStringLiteral(body) &&
|
||||||
|
objectProperty(node, "locationOffset") !== undefined
|
||||||
|
) {
|
||||||
|
documentSourceBodies.add(body);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return graphqlOperationFromDocumentNode(node);
|
||||||
|
}
|
||||||
|
if (t.isStringLiteral(node))
|
||||||
|
return documentSourceBodies.has(node)
|
||||||
|
? undefined
|
||||||
|
: graphqlOperationFromText(node.value);
|
||||||
|
if (t.isTemplateLiteral(node)) {
|
||||||
|
// A template recovered without a cooked value stays dynamic.
|
||||||
|
const cooked = node.quasis[0]?.value.cooked;
|
||||||
|
return cooked === undefined || cooked === null
|
||||||
|
? undefined
|
||||||
|
: graphqlOperationFromText(cooked);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -47,6 +47,7 @@ import { collectJavaScriptDerivedSemanticsSteps } from "./javascriptSemanticDeri
|
|||||||
import { evaluateSemanticProvenance } from "./javascriptSemanticValues.js";
|
import { evaluateSemanticProvenance } from "./javascriptSemanticValues.js";
|
||||||
import {
|
import {
|
||||||
collectElectronMemberWritesSteps,
|
collectElectronMemberWritesSteps,
|
||||||
|
electronExportPath,
|
||||||
electronMemberPath,
|
electronMemberPath,
|
||||||
ELECTRON_MODULE,
|
ELECTRON_MODULE,
|
||||||
} from "./javascriptElectronMemberWrites.js";
|
} from "./javascriptElectronMemberWrites.js";
|
||||||
@@ -59,6 +60,8 @@ import {
|
|||||||
type ParsedJavaScriptSource,
|
type ParsedJavaScriptSource,
|
||||||
} from "./javascriptSourceParser.js";
|
} from "./javascriptSourceParser.js";
|
||||||
|
|
||||||
|
import { isJavaScriptRpcClientFactory } from "./javascriptRpcEndpoints.js";
|
||||||
|
|
||||||
interface BindPatternInput {
|
interface BindPatternInput {
|
||||||
readonly pattern: t.Node;
|
readonly pattern: t.Node;
|
||||||
readonly initializer: t.Node | null;
|
readonly initializer: t.Node | null;
|
||||||
@@ -181,6 +184,40 @@ export function* analyzeParsedJavaScriptSemanticsSteps(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve tRPC client roots by lexical identity, excluding overwritten bindings. */
|
||||||
|
export function* classifyParsedJavaScriptRpcClientRootsSteps(
|
||||||
|
file: ParsedJavaScriptSource,
|
||||||
|
): Generator<void, ReadonlySet<t.Node>> {
|
||||||
|
const state = createState(file.program);
|
||||||
|
yield* collectDefinitionsSteps(file.program, state);
|
||||||
|
const roots = new Set<t.Node>();
|
||||||
|
const clients = new Map<string, boolean>();
|
||||||
|
yield* traverseJavaScriptAstSteps(file.program, {
|
||||||
|
enter: (node) => {
|
||||||
|
if (!t.isMemberExpression(node) && !t.isOptionalMemberExpression(node))
|
||||||
|
return;
|
||||||
|
const root = node.object;
|
||||||
|
if (!t.isIdentifier(root)) return;
|
||||||
|
const binding = resolveSemanticBindingState(state, root, root.name);
|
||||||
|
if (binding === undefined) return;
|
||||||
|
let client = clients.get(binding.bindingId);
|
||||||
|
if (client === undefined) {
|
||||||
|
const [initializer] = binding.initializers;
|
||||||
|
client =
|
||||||
|
initializer !== undefined &&
|
||||||
|
initializer.projection.length === 0 &&
|
||||||
|
binding.initializers.length === 1 &&
|
||||||
|
!binding.definitions.some(({ kind }) => kind === "assignment") &&
|
||||||
|
!state.conditionalInitializers.has(initializer.node) &&
|
||||||
|
isJavaScriptRpcClientFactory(initializer.node);
|
||||||
|
clients.set(binding.bindingId, client);
|
||||||
|
}
|
||||||
|
if (client) roots.add(root);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return roots;
|
||||||
|
}
|
||||||
|
|
||||||
/** Receiver identity facts for the overloaded `.open` syntax only. */
|
/** Receiver identity facts for the overloaded `.open` syntax only. */
|
||||||
export type JavaScriptOpenReceiverFact =
|
export type JavaScriptOpenReceiverFact =
|
||||||
| "window"
|
| "window"
|
||||||
@@ -349,7 +386,7 @@ export function* classifyParsedJavaScriptElectronBindingsSteps(
|
|||||||
}
|
}
|
||||||
const origin = semanticRequireOrigin(root, state);
|
const origin = semanticRequireOrigin(root, state);
|
||||||
return origin !== undefined && ELECTRON_MODULE.test(origin.specifier)
|
return origin !== undefined && ELECTRON_MODULE.test(origin.specifier)
|
||||||
? origin.importedPath
|
? electronExportPath(origin.importedPath)
|
||||||
: undefined;
|
: undefined;
|
||||||
};
|
};
|
||||||
const writes = yield* collectElectronMemberWritesSteps(
|
const writes = yield* collectElectronMemberWritesSteps(
|
||||||
@@ -400,7 +437,7 @@ const electronExport = (
|
|||||||
? provenance.origins[0]
|
? provenance.origins[0]
|
||||||
: undefined);
|
: undefined);
|
||||||
return origin !== undefined && ELECTRON_MODULE.test(origin.specifier)
|
return origin !== undefined && ELECTRON_MODULE.test(origin.specifier)
|
||||||
? origin.importedPath
|
? electronExportPath(origin.importedPath)
|
||||||
: null;
|
: null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
inspectCall,
|
inspectCall,
|
||||||
inspectRouteProperty,
|
inspectRouteProperty,
|
||||||
inspectRoleProperty,
|
inspectRoleProperty,
|
||||||
|
inspectRpcNode,
|
||||||
} from "./javascriptStaticAnalysisCalls.js";
|
} from "./javascriptStaticAnalysisCalls.js";
|
||||||
import {
|
import {
|
||||||
inspectBundlerRegistration,
|
inspectBundlerRegistration,
|
||||||
@@ -37,6 +38,7 @@ import {
|
|||||||
import type { JavaScriptStaticAnalysis } from "./javascriptStaticAnalysisTypes.js";
|
import type { JavaScriptStaticAnalysis } from "./javascriptStaticAnalysisTypes.js";
|
||||||
import {
|
import {
|
||||||
classifyParsedJavaScriptElectronBindingsSteps,
|
classifyParsedJavaScriptElectronBindingsSteps,
|
||||||
|
classifyParsedJavaScriptRpcClientRootsSteps,
|
||||||
classifyParsedJavaScriptOpenReceivers,
|
classifyParsedJavaScriptOpenReceivers,
|
||||||
type JavaScriptOpenReceiverFact,
|
type JavaScriptOpenReceiverFact,
|
||||||
} from "./javascriptSemanticAnalysis.js";
|
} from "./javascriptSemanticAnalysis.js";
|
||||||
@@ -45,6 +47,8 @@ import {
|
|||||||
type ParsedJavaScriptSource,
|
type ParsedJavaScriptSource,
|
||||||
} from "./javascriptSourceParser.js";
|
} from "./javascriptSourceParser.js";
|
||||||
|
|
||||||
|
import { createJavaScriptRpcScan } from "./javascriptRpcEndpoints.js";
|
||||||
|
|
||||||
/** Parse one JavaScript artifact and recover static structure only. */
|
/** Parse one JavaScript artifact and recover static structure only. */
|
||||||
export const analyzeJavaScriptStaticSource = (
|
export const analyzeJavaScriptStaticSource = (
|
||||||
source: string,
|
source: string,
|
||||||
@@ -78,11 +82,13 @@ export function* analyzeParsedJavaScriptStaticSourceSteps(
|
|||||||
const accumulator = createJavaScriptAnalysisAccumulator(source.length);
|
const accumulator = createJavaScriptAnalysisAccumulator(source.length);
|
||||||
const electronBindings =
|
const electronBindings =
|
||||||
yield* classifyParsedJavaScriptElectronBindingsSteps(file);
|
yield* classifyParsedJavaScriptElectronBindingsSteps(file);
|
||||||
|
const clientRoots = yield* classifyParsedJavaScriptRpcClientRootsSteps(file);
|
||||||
yield* traverseStaticSourceSteps(file, {
|
yield* traverseStaticSourceSteps(file, {
|
||||||
source,
|
source,
|
||||||
accumulator,
|
accumulator,
|
||||||
...(openReceiverFacts === undefined ? {} : { openReceiverFacts }),
|
...(openReceiverFacts === undefined ? {} : { openReceiverFacts }),
|
||||||
electronBindings,
|
electronBindings,
|
||||||
|
rpcScan: createJavaScriptRpcScan(source, clientRoots),
|
||||||
});
|
});
|
||||||
addSourceMapDirectives(source, file.comments ?? [], accumulator);
|
addSourceMapDirectives(source, file.comments ?? [], accumulator);
|
||||||
return finalizeStaticAnalysis(source, file, accumulator);
|
return finalizeStaticAnalysis(source, file, accumulator);
|
||||||
@@ -223,4 +229,5 @@ const inspectNode = (
|
|||||||
inspectRouteProperty(node, findings);
|
inspectRouteProperty(node, findings);
|
||||||
inspectRoleProperty(node, findings);
|
inspectRoleProperty(node, findings);
|
||||||
}
|
}
|
||||||
|
inspectRpcNode(node, findings);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -201,6 +201,13 @@ const inspectEndpointCall = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Record tRPC procedures, tRPC link URLs and named GraphQL operations. */
|
||||||
|
export const inspectRpcNode = (node: t.Node, context: FindingContext): void => {
|
||||||
|
const rpc = context.rpcScan.inspect(node);
|
||||||
|
if (rpc !== undefined)
|
||||||
|
addEndpoint(context, { node, kind: "network", ...rpc });
|
||||||
|
};
|
||||||
|
|
||||||
export const inspectRouteProperty = (
|
export const inspectRouteProperty = (
|
||||||
node: t.ObjectProperty,
|
node: t.ObjectProperty,
|
||||||
context: FindingContext,
|
context: FindingContext,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type * as t from "@babel/types";
|
import type * as t from "@babel/types";
|
||||||
|
import type { JavaScriptRpcScan } from "./javascriptRpcEndpoints.js";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
JavaScriptBundlerRegistration,
|
JavaScriptBundlerRegistration,
|
||||||
@@ -57,6 +58,7 @@ export interface JavaScriptAnalysisAccumulator {
|
|||||||
|
|
||||||
/** Shared source and accumulator for helper inspections. */
|
/** Shared source and accumulator for helper inspections. */
|
||||||
export interface JavaScriptFindingContext {
|
export interface JavaScriptFindingContext {
|
||||||
|
readonly rpcScan: JavaScriptRpcScan;
|
||||||
readonly source: string;
|
readonly source: string;
|
||||||
readonly accumulator: JavaScriptAnalysisAccumulator;
|
readonly accumulator: JavaScriptAnalysisAccumulator;
|
||||||
readonly openReceiverFacts?: ReadonlyMap<
|
readonly openReceiverFacts?: ReadonlyMap<
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { NativeAotParserLimits } from "./nativeAotPe.js";
|
||||||
|
import {
|
||||||
|
NATIVE_AOT_MAX_REPORT_BYTES,
|
||||||
|
parseNativeAotPe,
|
||||||
|
} from "./nativeAotPe.js";
|
||||||
|
import {
|
||||||
|
nativeAotPeDigest,
|
||||||
|
nativeAotPeFixture,
|
||||||
|
} from "../../../tests/fixtures/nativeaotPe.js";
|
||||||
|
|
||||||
|
const REPORT_BUDGET = NATIVE_AOT_MAX_REPORT_BYTES;
|
||||||
|
|
||||||
|
describe("NativeAOT parser resource budgets", () => {
|
||||||
|
it("reports source-cap rejection without hashing or inventing omitted-byte coverage", () => {
|
||||||
|
const bytes = nativeAotPeFixture();
|
||||||
|
const sha = nativeAotPeDigest(bytes);
|
||||||
|
const limits: NativeAotParserLimits = {
|
||||||
|
sourceBytes: bytes.length - 1,
|
||||||
|
workUnits: 1024,
|
||||||
|
workingMemoryBytes: 1024,
|
||||||
|
};
|
||||||
|
const report = parseNativeAotPe(
|
||||||
|
bytes,
|
||||||
|
sha,
|
||||||
|
REPORT_BUDGET,
|
||||||
|
undefined,
|
||||||
|
limits,
|
||||||
|
).summary;
|
||||||
|
expect(report).toMatchObject({
|
||||||
|
status: "partial",
|
||||||
|
analysis_artifact_sha256: sha,
|
||||||
|
header_address: null,
|
||||||
|
coverage: {
|
||||||
|
source_bytes_examined: 0,
|
||||||
|
source_byte_budget_bytes: bytes.length - 1,
|
||||||
|
truncation_reason: "source-byte-budget",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(report.coverage.source_bytes_omitted).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("NativeAOT parser report admission", () => {
|
||||||
|
it("never returns an over-budget report from early exits", () => {
|
||||||
|
const bytes = nativeAotPeFixture();
|
||||||
|
const sha = nativeAotPeDigest(bytes);
|
||||||
|
expect(() =>
|
||||||
|
parseNativeAotPe(bytes, sha, 0, undefined, {
|
||||||
|
sourceBytes: bytes.length - 1,
|
||||||
|
workUnits: 1024,
|
||||||
|
workingMemoryBytes: 1024,
|
||||||
|
}),
|
||||||
|
).toThrow(/minimum truthful report/iu);
|
||||||
|
const nonPe = Buffer.from("not a PE image");
|
||||||
|
expect(() => parseNativeAotPe(nonPe, nativeAotPeDigest(nonPe), 0)).toThrow(
|
||||||
|
/minimum truthful report/iu,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not claim a missing RTR header when signature coverage hits the work limit", () => {
|
||||||
|
const bytes = nativeAotPeFixture();
|
||||||
|
const limits: NativeAotParserLimits = {
|
||||||
|
sourceBytes: bytes.length,
|
||||||
|
workUnits: 8,
|
||||||
|
workingMemoryBytes: 1024,
|
||||||
|
};
|
||||||
|
const report = parseNativeAotPe(
|
||||||
|
bytes,
|
||||||
|
nativeAotPeDigest(bytes),
|
||||||
|
REPORT_BUDGET,
|
||||||
|
undefined,
|
||||||
|
limits,
|
||||||
|
).summary;
|
||||||
|
expect(report).toMatchObject({
|
||||||
|
status: "partial",
|
||||||
|
header_address: null,
|
||||||
|
truncated: true,
|
||||||
|
coverage: {
|
||||||
|
rtr_signature_scan_bytes_examined: 8,
|
||||||
|
rtr_signature_scan_bytes_omitted: expect.any(Number),
|
||||||
|
truncation_reason: "work-unit-budget",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(report.reason).not.toMatch(/No supported ReadyToRun directory/iu);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not claim a missing root when table discovery is incomplete", () => {
|
||||||
|
const bytes = nativeAotPeFixture();
|
||||||
|
const report = parseNativeAotPe(
|
||||||
|
bytes,
|
||||||
|
nativeAotPeDigest(bytes),
|
||||||
|
REPORT_BUDGET,
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
sourceBytes: bytes.length,
|
||||||
|
workUnits: 10_000,
|
||||||
|
workingMemoryBytes: 1024,
|
||||||
|
},
|
||||||
|
).summary;
|
||||||
|
expect(report.truncated).toBe(true);
|
||||||
|
expect(report.reason).toMatch(/before determining whether.*root exists/iu);
|
||||||
|
expect(report.reason).not.toMatch(
|
||||||
|
/no unique System\.Object.*fully examined/iu,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("NativeAOT detail and summary byte accounting", () => {
|
||||||
|
it("admits serialized MethodTable memory descriptors before emitting them", () => {
|
||||||
|
const bytes = nativeAotPeFixture();
|
||||||
|
const sha = nativeAotPeDigest(bytes);
|
||||||
|
const detail = (budget: number) =>
|
||||||
|
parseNativeAotPe(bytes, sha, REPORT_BUDGET).readTypeDetail(
|
||||||
|
"0x140002280",
|
||||||
|
budget,
|
||||||
|
);
|
||||||
|
const complete = detail(REPORT_BUDGET);
|
||||||
|
expect(complete?.derived_memory).not.toBeNull();
|
||||||
|
let low = 0;
|
||||||
|
let high = Buffer.byteLength(JSON.stringify(complete));
|
||||||
|
while (low < high) {
|
||||||
|
const middle = Math.floor((low + high) / 2);
|
||||||
|
if (detail(middle)?.derived_memory != null) high = middle;
|
||||||
|
else low = middle + 1;
|
||||||
|
}
|
||||||
|
const atBoundary = detail(low);
|
||||||
|
const belowBoundary = detail(low - 1);
|
||||||
|
expect(atBoundary?.derived_memory).not.toBeNull();
|
||||||
|
expect(Buffer.byteLength(JSON.stringify(atBoundary))).toBeLessThanOrEqual(
|
||||||
|
low,
|
||||||
|
);
|
||||||
|
expect(belowBoundary?.derived_memory ?? null).toBeNull();
|
||||||
|
expect(belowBoundary?.diagnostics.join(" ")).toContain(
|
||||||
|
"omitted because the final serialized report exceeded its byte budget",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
belowBoundary === undefined
|
||||||
|
? 0
|
||||||
|
: Buffer.byteLength(JSON.stringify(belowBoundary)),
|
||||||
|
).toBeLessThanOrEqual(low - 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("truncates report rows before exceeding its JSON byte budget", () => {
|
||||||
|
const bytes = nativeAotPeFixture();
|
||||||
|
const sha = nativeAotPeDigest(bytes);
|
||||||
|
const complete = parseNativeAotPe(bytes, sha, REPORT_BUDGET);
|
||||||
|
const fullBytes = Buffer.byteLength(JSON.stringify(complete.summary));
|
||||||
|
let budget = fullBytes - 1;
|
||||||
|
let summary: ReturnType<typeof parseNativeAotPe>["summary"] | undefined;
|
||||||
|
while (budget > 0) {
|
||||||
|
try {
|
||||||
|
const candidate = parseNativeAotPe(bytes, sha, budget);
|
||||||
|
if (candidate.summary.truncated) {
|
||||||
|
summary = candidate.summary;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Budgets below the fixed report header cannot carry an honest summary.
|
||||||
|
}
|
||||||
|
budget--;
|
||||||
|
}
|
||||||
|
if (summary === undefined)
|
||||||
|
throw new Error("No partial summary budget found");
|
||||||
|
expect(summary.truncated).toBe(true);
|
||||||
|
expect(
|
||||||
|
(summary.types_omitted ?? 0) +
|
||||||
|
(summary.frozen_strings_omitted ?? 0) +
|
||||||
|
(summary.coverage.scan_bytes_omitted ?? 0) +
|
||||||
|
(summary.coverage.frozen_scan_bytes_omitted ?? 0),
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
expect(Buffer.byteLength(JSON.stringify(summary))).toBeLessThanOrEqual(
|
||||||
|
budget,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { nativeMetadataRecoverySummarySchema } from "./nativeMetadataRecovery.js";
|
||||||
|
import {
|
||||||
|
NATIVE_AOT_MAX_REPORT_BYTES,
|
||||||
|
parseNativeAotPe,
|
||||||
|
} from "./nativeAotPe.js";
|
||||||
|
import {
|
||||||
|
nativeAotPeDigest,
|
||||||
|
nativeAotPeFixture,
|
||||||
|
} from "../../../tests/fixtures/nativeaotPe.js";
|
||||||
|
const SUMMARY_BUDGET = NATIVE_AOT_MAX_REPORT_BYTES;
|
||||||
|
|
||||||
|
describe("read-only NativeAOT PE parser", () => {
|
||||||
|
it("recovers a bounded RTR9.1 type graph and frozen literal from captured bytes", () => {
|
||||||
|
const bytes = nativeAotPeFixture();
|
||||||
|
const before = Buffer.from(bytes);
|
||||||
|
const parsed = parseNativeAotPe(
|
||||||
|
bytes,
|
||||||
|
nativeAotPeDigest(bytes),
|
||||||
|
SUMMARY_BUDGET,
|
||||||
|
"0x140000000",
|
||||||
|
);
|
||||||
|
expect(bytes).toEqual(before);
|
||||||
|
expect(parsed.summary).toMatchObject({
|
||||||
|
format: "dotnet-nativeaot",
|
||||||
|
status: "complete",
|
||||||
|
analysis_mode: "read-only-derived-overlay",
|
||||||
|
format_major: 9,
|
||||||
|
format_minor: 1,
|
||||||
|
method_tables: 3,
|
||||||
|
frozen_strings: [
|
||||||
|
{ address: "0x140002300", value: "REA_NATIVEAOT_FROZEN" },
|
||||||
|
],
|
||||||
|
derived_overlay: { address: "0x140002380", size_bytes: 1 },
|
||||||
|
coverage: {
|
||||||
|
basis: "raw-image-and-rehydrated-pointer-scan",
|
||||||
|
method_tables_recovered: 3,
|
||||||
|
frozen_string_candidates: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(parsed.readTypeDetail("0x140002280", SUMMARY_BUDGET)).toMatchObject({
|
||||||
|
source: "read-only-derived-overlay",
|
||||||
|
method_table_address: "0x140002280",
|
||||||
|
related_type: { address: "0x140002200", type: null },
|
||||||
|
virtual_slots: [{ slot: 0, target_address: "0x140001040" }],
|
||||||
|
});
|
||||||
|
expect(nativeMetadataRecoverySummarySchema.parse(parsed.summary)).toEqual(
|
||||||
|
parsed.summary,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed on snapshot identity mismatch", () => {
|
||||||
|
const bytes = nativeAotPeFixture();
|
||||||
|
expect(() =>
|
||||||
|
parseNativeAotPe(bytes, "0".repeat(64), SUMMARY_BUDGET),
|
||||||
|
).toThrow(/digest/iu);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a detected but unsupported RTR version as partial without guessed types", () => {
|
||||||
|
const bytes = nativeAotPeFixture(10, 0);
|
||||||
|
const parsed = parseNativeAotPe(
|
||||||
|
bytes,
|
||||||
|
nativeAotPeDigest(bytes),
|
||||||
|
SUMMARY_BUDGET,
|
||||||
|
);
|
||||||
|
expect(parsed.summary).toMatchObject({
|
||||||
|
status: "partial",
|
||||||
|
format_major: 10,
|
||||||
|
format_minor: 0,
|
||||||
|
method_tables: 0,
|
||||||
|
types: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not claim an image mapping when Ghidra rebased the image", () => {
|
||||||
|
const bytes = nativeAotPeFixture();
|
||||||
|
expect(
|
||||||
|
parseNativeAotPe(
|
||||||
|
bytes,
|
||||||
|
nativeAotPeDigest(bytes),
|
||||||
|
SUMMARY_BUDGET,
|
||||||
|
"0x180000000",
|
||||||
|
).summary.reason,
|
||||||
|
).toMatch(/preferred image base/iu);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an unsupported hydration opcode as partial without mutating the snapshot", () => {
|
||||||
|
const bytes = nativeAotPeFixture();
|
||||||
|
bytes.writeUInt8(6, 0x704);
|
||||||
|
const before = Buffer.from(bytes);
|
||||||
|
const parsed = parseNativeAotPe(
|
||||||
|
bytes,
|
||||||
|
nativeAotPeDigest(bytes),
|
||||||
|
SUMMARY_BUDGET,
|
||||||
|
);
|
||||||
|
expect(bytes).toEqual(before);
|
||||||
|
expect(parsed.summary).toMatchObject({
|
||||||
|
status: "partial",
|
||||||
|
derived_overlay: null,
|
||||||
|
diagnostics: [
|
||||||
|
expect.stringContaining("Unsupported DEHYDRATED_DATA command"),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,8 @@ const relatedType = z.strictObject({ address, type: z.string().nullable() });
|
|||||||
/** Runtime-format metadata recovered in an analysis image, with explicit inference boundaries. */
|
/** Runtime-format metadata recovered in an analysis image, with explicit inference boundaries. */
|
||||||
export const nativeMetadataRecoverySchema = z.strictObject({
|
export const nativeMetadataRecoverySchema = z.strictObject({
|
||||||
format: z.literal("dotnet-nativeaot"),
|
format: z.literal("dotnet-nativeaot"),
|
||||||
|
/** Identifies parser-only facts without claiming Ghidra defined a DataType. */
|
||||||
|
source: z.enum(["analysis-database", "read-only-derived-overlay"]).optional(),
|
||||||
status: z.enum(["complete", "partial"]),
|
status: z.enum(["complete", "partial"]),
|
||||||
header_address: address,
|
header_address: address,
|
||||||
format_major: z.number().int().nonnegative(),
|
format_major: z.number().int().nonnegative(),
|
||||||
@@ -25,19 +27,29 @@ export const nativeMetadataRecoverySchema = z.strictObject({
|
|||||||
basis: z.literal("method-table-pointer"),
|
basis: z.literal("method-table-pointer"),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
derived_memory: z.strictObject({
|
derived_memory: z
|
||||||
address,
|
.strictObject({
|
||||||
size_bytes: z.number().int().nonnegative(),
|
address,
|
||||||
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
size_bytes: z.number().int().nonnegative(),
|
||||||
file_offset: z.null(),
|
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||||
}),
|
file_offset: z.null(),
|
||||||
|
})
|
||||||
|
.nullable(),
|
||||||
|
truncated: z.boolean().optional(),
|
||||||
|
virtual_slots_omitted: z.number().int().nonnegative().optional(),
|
||||||
|
interfaces_omitted: z.number().int().nonnegative().optional(),
|
||||||
|
output_budget_bytes: z.number().int().nonnegative().optional(),
|
||||||
diagnostics: z.array(z.string()),
|
diagnostics: z.array(z.string()),
|
||||||
limitations: z.array(z.string()),
|
limitations: z.array(z.string()),
|
||||||
});
|
});
|
||||||
|
export type NativeMetadataRecovery = z.infer<
|
||||||
|
typeof nativeMetadataRecoverySchema
|
||||||
|
>;
|
||||||
|
|
||||||
/** Discover a recovered runtime format and reusable type identities from the loaded image. */
|
/** Discover a recovered runtime format and reusable type identities from the loaded image. */
|
||||||
export const nativeMetadataRecoverySummarySchema = z.strictObject({
|
export const nativeMetadataRecoverySummarySchema = z.strictObject({
|
||||||
format: z.literal("dotnet-nativeaot"),
|
format: z.literal("dotnet-nativeaot"),
|
||||||
|
analysis_mode: z.literal("read-only-derived-overlay").optional(),
|
||||||
status: z.enum(["complete", "partial", "not_applicable"]),
|
status: z.enum(["complete", "partial", "not_applicable"]),
|
||||||
reason: z.string().nullable(),
|
reason: z.string().nullable(),
|
||||||
analysis_artifact_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
analysis_artifact_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||||
@@ -47,14 +59,76 @@ export const nativeMetadataRecoverySummarySchema = z.strictObject({
|
|||||||
format_minor: z.number().int().nonnegative().nullable(),
|
format_minor: z.number().int().nonnegative().nullable(),
|
||||||
method_tables: z.number().int().nonnegative(),
|
method_tables: z.number().int().nonnegative(),
|
||||||
types: z.array(relatedType),
|
types: z.array(relatedType),
|
||||||
derived_memory: nativeMetadataRecoverySchema.shape.derived_memory.nullable(),
|
frozen_strings: z
|
||||||
|
.array(z.strictObject({ address, value: z.string() }))
|
||||||
|
.optional(),
|
||||||
|
truncated: z.boolean().optional(),
|
||||||
|
types_omitted: z.number().int().nonnegative().optional(),
|
||||||
|
frozen_strings_omitted: z.number().int().nonnegative().optional(),
|
||||||
|
output_budget_bytes: z.number().int().nonnegative().optional(),
|
||||||
|
derived_overlay: z
|
||||||
|
.strictObject({
|
||||||
|
address,
|
||||||
|
size_bytes: z.number().int().nonnegative(),
|
||||||
|
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||||
|
})
|
||||||
|
.nullable()
|
||||||
|
.optional(),
|
||||||
|
derived_memory: nativeMetadataRecoverySchema.shape.derived_memory,
|
||||||
coverage: z
|
coverage: z
|
||||||
.strictObject({
|
.strictObject({
|
||||||
frozen_object_candidates: z.number().int().nonnegative(),
|
frozen_object_candidates: z.number().int().nonnegative().optional(),
|
||||||
frozen_objects_annotated: z.number().int().nonnegative(),
|
frozen_objects_annotated: z.number().int().nonnegative().optional(),
|
||||||
basis: z.literal(
|
pointer_candidates_examined: z.number().int().nonnegative().optional(),
|
||||||
|
pointer_slots_examined: z.number().int().nonnegative().optional(),
|
||||||
|
scan_bytes_examined: z.number().int().nonnegative().optional(),
|
||||||
|
scan_bytes_omitted: z.number().int().nonnegative().optional(),
|
||||||
|
rtr_signature_scan_bytes_examined: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.nonnegative()
|
||||||
|
.optional(),
|
||||||
|
rtr_signature_scan_bytes_omitted: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.nonnegative()
|
||||||
|
.optional(),
|
||||||
|
method_table_validation_items_examined: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.nonnegative()
|
||||||
|
.optional(),
|
||||||
|
work_units_examined: z.number().int().nonnegative().optional(),
|
||||||
|
method_tables_recovered: z.number().int().nonnegative().optional(),
|
||||||
|
frozen_string_candidates: z.number().int().nonnegative().optional(),
|
||||||
|
frozen_scan_bytes_examined: z.number().int().nonnegative().optional(),
|
||||||
|
frozen_scan_bytes_omitted: z.number().int().nonnegative().optional(),
|
||||||
|
frozen_string_data_bytes_examined: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.nonnegative()
|
||||||
|
.optional(),
|
||||||
|
source_bytes_examined: z.number().int().nonnegative().optional(),
|
||||||
|
source_bytes_omitted: z.number().int().nonnegative().optional(),
|
||||||
|
source_byte_budget_bytes: z.number().int().nonnegative().optional(),
|
||||||
|
work_budget_units: z.number().int().nonnegative().optional(),
|
||||||
|
working_memory_budget_bytes: z.number().int().nonnegative().optional(),
|
||||||
|
working_memory_bytes_peak: z.number().int().nonnegative().optional(),
|
||||||
|
truncation_reason: z
|
||||||
|
.enum([
|
||||||
|
"source-byte-budget",
|
||||||
|
"work-unit-budget",
|
||||||
|
"working-memory-budget",
|
||||||
|
"response-budget",
|
||||||
|
])
|
||||||
|
.optional(),
|
||||||
|
basis: z.enum([
|
||||||
"rehydrated-pointer-candidates-and-committed-instance-types",
|
"rehydrated-pointer-candidates-and-committed-instance-types",
|
||||||
),
|
"raw-image-pointer-scan",
|
||||||
|
"raw-image-and-rehydrated-pointer-scan",
|
||||||
|
"unsupported-layout",
|
||||||
|
"no-rtr-header",
|
||||||
|
]),
|
||||||
})
|
})
|
||||||
.nullable(),
|
.nullable(),
|
||||||
diagnostics: z.array(z.string()),
|
diagnostics: z.array(z.string()),
|
||||||
|
|||||||
@@ -603,3 +603,56 @@ it("retains direct and literal computed call names", () => {
|
|||||||
expect.objectContaining({ name: "literal-tool" }),
|
expect.objectContaining({ name: "literal-tool" }),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("recognizes tRPC procedures and named GraphQL operations in captured scripts", () => {
|
||||||
|
const result = analyzeCapturedWebBundle(
|
||||||
|
inspection(`
|
||||||
|
const n = createTRPCClient({ links: [httpBatchLink({ url: "/api/trpc" })] });
|
||||||
|
e.invoice.list.useQuery({ competencia: "2026-09" });
|
||||||
|
n.empresa.byCnpj.query("00000000000191");
|
||||||
|
const q = "query ListarNotas($competencia: String!) { notas { id } }";
|
||||||
|
this.client.query({ query: q });
|
||||||
|
`),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
result.observations.endpoints.map(({ value, mechanism }) => ({
|
||||||
|
value,
|
||||||
|
mechanism,
|
||||||
|
})),
|
||||||
|
).toEqual([
|
||||||
|
{ value: "/api/trpc", mechanism: "trpc:link:httpBatchLink" },
|
||||||
|
{ value: "invoice.list", mechanism: "trpc:procedure:useQuery" },
|
||||||
|
{ value: "empresa.byCnpj", mechanism: "trpc:procedure:query" },
|
||||||
|
{ value: "ListarNotas", mechanism: "graphql:query" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps RPC lexical identity and bundled factory recognition in captured scripts", () => {
|
||||||
|
const longOperation = "Operation" + "x".repeat(512);
|
||||||
|
const document =
|
||||||
|
"#" +
|
||||||
|
"comment".repeat(700) +
|
||||||
|
"\nquery " +
|
||||||
|
longOperation +
|
||||||
|
" { users { id } }";
|
||||||
|
const result = analyzeCapturedWebBundle(
|
||||||
|
inspection(`
|
||||||
|
function first() { const c = createTRPCClient({}); }
|
||||||
|
function unrelated(c) { c.users.query("select 1"); }
|
||||||
|
const c = (0, t.createTRPCClient)({ links: [(0, t.httpBatchLink)({ url: "/api/rpc" })] });
|
||||||
|
c.posts.query();
|
||||||
|
const doc = ${JSON.stringify(document)};
|
||||||
|
`),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
result.observations.endpoints.map(({ value, mechanism }) => ({
|
||||||
|
value,
|
||||||
|
mechanism,
|
||||||
|
})),
|
||||||
|
).toEqual([
|
||||||
|
{ value: "/api/rpc", mechanism: "trpc:link:httpBatchLink" },
|
||||||
|
{ value: "posts", mechanism: "trpc:procedure:query" },
|
||||||
|
{ value: longOperation, mechanism: "graphql:query" },
|
||||||
|
]);
|
||||||
|
expect(result.completeness.status).toBe("complete");
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,8 +5,14 @@ import { sanitizeEndpointCandidate } from "./browserObservation.js";
|
|||||||
import { compositeKey } from "./unicodeCodePointOrder.js";
|
import { compositeKey } from "./unicodeCodePointOrder.js";
|
||||||
import type { WebPageInspection } from "./browserObservationSchemas.js";
|
import type { WebPageInspection } from "./browserObservationSchemas.js";
|
||||||
import type { WebBundleAnalysis } from "./webBundleAnalysis.js";
|
import type { WebBundleAnalysis } from "./webBundleAnalysis.js";
|
||||||
import { traverseJavaScriptAst } from "./javascript/javascriptSemanticTraversal.js";
|
import {
|
||||||
import { classifyParsedJavaScriptOpenReceivers } from "./javascript/javascriptSemanticAnalysis.js";
|
completeSemanticSteps,
|
||||||
|
traverseJavaScriptAst,
|
||||||
|
} from "./javascript/javascriptSemanticTraversal.js";
|
||||||
|
import {
|
||||||
|
classifyParsedJavaScriptOpenReceivers,
|
||||||
|
classifyParsedJavaScriptRpcClientRootsSteps,
|
||||||
|
} from "./javascript/javascriptSemanticAnalysis.js";
|
||||||
import type { JavaScriptOpenReceiverFact } from "./javascript/javascriptSemanticAnalysis.js";
|
import type { JavaScriptOpenReceiverFact } from "./javascript/javascriptSemanticAnalysis.js";
|
||||||
import { semanticStaticPropertyName } from "./javascript/javascriptAstValues.js";
|
import { semanticStaticPropertyName } from "./javascript/javascriptAstValues.js";
|
||||||
import {
|
import {
|
||||||
@@ -22,6 +28,7 @@ import {
|
|||||||
resolveSpecifier,
|
resolveSpecifier,
|
||||||
isUrlLikeModuleSpecifier,
|
isUrlLikeModuleSpecifier,
|
||||||
} from "./webBundleAnalyzerAst.js";
|
} from "./webBundleAnalyzerAst.js";
|
||||||
|
import { createJavaScriptRpcScan } from "./javascript/javascriptRpcEndpoints.js";
|
||||||
|
|
||||||
type BundleObservations = WebBundleAnalysis["observations"];
|
type BundleObservations = WebBundleAnalysis["observations"];
|
||||||
type ChunkEdge = BundleObservations["chunks"]["edges"][number];
|
type ChunkEdge = BundleObservations["chunks"]["edges"][number];
|
||||||
@@ -66,10 +73,24 @@ export const analyzeScript = (
|
|||||||
accumulator.parsedScripts += 1;
|
accumulator.parsedScripts += 1;
|
||||||
detectVendorFingerprints(script, accumulator);
|
detectVendorFingerprints(script, accumulator);
|
||||||
const openReceiverFacts = classifyParsedJavaScriptOpenReceivers(file);
|
const openReceiverFacts = classifyParsedJavaScriptOpenReceivers(file);
|
||||||
|
const rpc = createJavaScriptRpcScan(
|
||||||
|
script.source.artifact.text,
|
||||||
|
completeSemanticSteps(classifyParsedJavaScriptRpcClientRootsSteps(file)),
|
||||||
|
);
|
||||||
traverseJavaScriptAst(file, {
|
traverseJavaScriptAst(file, {
|
||||||
enter: (node) => {
|
enter: (node) => {
|
||||||
accumulator.visitedNodes += 1;
|
accumulator.visitedNodes += 1;
|
||||||
inspectNode(script, node, accumulator, openReceiverFacts);
|
inspectNode(script, node, accumulator, openReceiverFacts);
|
||||||
|
const endpoint = rpc.inspect(node);
|
||||||
|
if (endpoint !== undefined)
|
||||||
|
addFinding({
|
||||||
|
collection: accumulator.endpoints,
|
||||||
|
script,
|
||||||
|
rawValue: endpoint.value,
|
||||||
|
mechanism: endpoint.mechanism,
|
||||||
|
node,
|
||||||
|
accumulator,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ import {
|
|||||||
ghidraMipsProfileParameters,
|
ghidraMipsProfileParameters,
|
||||||
ghidraMipsUnsupportedReason,
|
ghidraMipsUnsupportedReason,
|
||||||
} from "./GhidraMipsProfile.js";
|
} from "./GhidraMipsProfile.js";
|
||||||
|
import {
|
||||||
|
NATIVE_AOT_MAX_REPORT_BYTES,
|
||||||
|
NATIVE_AOT_MAX_SOURCE_BYTES,
|
||||||
|
NATIVE_AOT_MAX_WORKING_MEMORY_BYTES,
|
||||||
|
NATIVE_AOT_MAX_WORK_UNITS,
|
||||||
|
NATIVE_AOT_PROFILE_CONTRACT_REVISION,
|
||||||
|
} from "../domain/native/nativeAotPe.js";
|
||||||
|
|
||||||
/** Resolve version-bound, deterministic semantics before Ghidra imports a target. */
|
/** Resolve version-bound, deterministic semantics before Ghidra imports a target. */
|
||||||
export const resolveGhidraAnalysisProfile = (
|
export const resolveGhidraAnalysisProfile = (
|
||||||
@@ -69,6 +76,13 @@ export const resolveGhidraAnalysisProfile = (
|
|||||||
? {
|
? {
|
||||||
executable_role: target.executableRole ?? null,
|
executable_role: target.executableRole ?? null,
|
||||||
managed: target.managed ?? null,
|
managed: target.managed ?? null,
|
||||||
|
native_aot_metadata: {
|
||||||
|
contract_revision: NATIVE_AOT_PROFILE_CONTRACT_REVISION,
|
||||||
|
source_bytes: NATIVE_AOT_MAX_SOURCE_BYTES,
|
||||||
|
work_units: NATIVE_AOT_MAX_WORK_UNITS,
|
||||||
|
working_memory_bytes: NATIVE_AOT_MAX_WORKING_MEMORY_BYTES,
|
||||||
|
report_bytes: NATIVE_AOT_MAX_REPORT_BYTES,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
architecture: target.architecture ?? null,
|
architecture: target.architecture ?? null,
|
||||||
|
|||||||
@@ -43,6 +43,21 @@ import {
|
|||||||
} from "./GhidraSessionValues.js";
|
} from "./GhidraSessionValues.js";
|
||||||
import { createGhidraTargetSnapshot } from "./GhidraTargetSnapshot.js";
|
import { createGhidraTargetSnapshot } from "./GhidraTargetSnapshot.js";
|
||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
|
||||||
|
import {
|
||||||
|
openRegularFile,
|
||||||
|
RegularFileChangedError,
|
||||||
|
sameRegularFileState,
|
||||||
|
} from "../filesystem/RegularFile.js";
|
||||||
|
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
||||||
|
|
||||||
|
export type GhidraTargetSnapshot =
|
||||||
|
| { readonly kind: "captured"; readonly bytes: Buffer }
|
||||||
|
| {
|
||||||
|
readonly kind: "over-capacity";
|
||||||
|
readonly sourceBytesAtLeast: number;
|
||||||
|
readonly maximumBytes: number;
|
||||||
|
};
|
||||||
import { connectGhidraSocket } from "./GhidraSocketConnection.js";
|
import { connectGhidraSocket } from "./GhidraSocketConnection.js";
|
||||||
import {
|
import {
|
||||||
createGhidraEndpoint,
|
createGhidraEndpoint,
|
||||||
@@ -79,6 +94,7 @@ export class GhidraClient {
|
|||||||
#socketRoot: PrivateRuntimeRoot | undefined;
|
#socketRoot: PrivateRuntimeRoot | undefined;
|
||||||
#endpointPath: string | undefined;
|
#endpointPath: string | undefined;
|
||||||
#snapshotPath: string | undefined;
|
#snapshotPath: string | undefined;
|
||||||
|
readonly #snapshotOwners = new Set<OwnedFileHandle>();
|
||||||
#targetAdmission: JsonValue | undefined;
|
#targetAdmission: JsonValue | undefined;
|
||||||
#token: string | undefined;
|
#token: string | undefined;
|
||||||
// Retain authentication identities for diagnostics from late request settlement.
|
// Retain authentication identities for diagnostics from late request settlement.
|
||||||
@@ -227,7 +243,10 @@ export class GhidraClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Read the private immutable source for independent import verification. */
|
/** Read the private immutable source for independent import verification. */
|
||||||
async readTargetSnapshot(): Promise<Result<Buffer, GhidraSessionError>> {
|
async readTargetSnapshot(
|
||||||
|
maximumBytes?: number,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<Result<GhidraTargetSnapshot, GhidraSessionError>> {
|
||||||
if (this.#snapshotPath === undefined)
|
if (this.#snapshotPath === undefined)
|
||||||
return err(
|
return err(
|
||||||
this.#failure(
|
this.#failure(
|
||||||
@@ -236,11 +255,80 @@ export class GhidraClient {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
return ok(await readFile(this.#snapshotPath));
|
if (maximumBytes !== undefined) {
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 0)
|
||||||
|
throw new RangeError("Snapshot byte limit must be a safe integer");
|
||||||
|
const handle = await openRegularFile(this.#snapshotPath, {
|
||||||
|
symlinks: "reject",
|
||||||
|
});
|
||||||
|
const owner = new OwnedFileHandle(handle);
|
||||||
|
this.#snapshotOwners.add(owner);
|
||||||
|
let result: GhidraTargetSnapshot;
|
||||||
|
let readFailure: unknown;
|
||||||
|
try {
|
||||||
|
const metadata = await handle.stat();
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
if (metadata.size > maximumBytes)
|
||||||
|
result = {
|
||||||
|
kind: "over-capacity",
|
||||||
|
sourceBytesAtLeast: metadata.size,
|
||||||
|
maximumBytes,
|
||||||
|
};
|
||||||
|
else {
|
||||||
|
const bytes = await readBoundedFileBytes(
|
||||||
|
handle,
|
||||||
|
maximumBytes,
|
||||||
|
signal,
|
||||||
|
metadata.size,
|
||||||
|
);
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
const after = await handle.stat();
|
||||||
|
if (
|
||||||
|
bytes !== undefined &&
|
||||||
|
bytes.length === metadata.size &&
|
||||||
|
sameRegularFileState(metadata, after)
|
||||||
|
) {
|
||||||
|
result = { kind: "captured", bytes };
|
||||||
|
} else if (
|
||||||
|
bytes === undefined &&
|
||||||
|
after.isFile() &&
|
||||||
|
after.size > maximumBytes
|
||||||
|
) {
|
||||||
|
result = {
|
||||||
|
kind: "over-capacity",
|
||||||
|
sourceBytesAtLeast: after.size,
|
||||||
|
maximumBytes,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
throw new RegularFileChangedError(this.#snapshotPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
readFailure = cause;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await owner.close();
|
||||||
|
this.#snapshotOwners.delete(owner);
|
||||||
|
} catch (closeFailure: unknown) {
|
||||||
|
throw new AggregateError(
|
||||||
|
readFailure === undefined
|
||||||
|
? [closeFailure]
|
||||||
|
: [readFailure, closeFailure],
|
||||||
|
"Ghidra target snapshot read or descriptor cleanup failed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (readFailure !== undefined) throw readFailure;
|
||||||
|
return ok(result!);
|
||||||
|
}
|
||||||
|
return ok({
|
||||||
|
kind: "captured",
|
||||||
|
bytes: await readFile(this.#snapshotPath),
|
||||||
|
});
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
return err(
|
return err(
|
||||||
this.#failure(
|
this.#failure(
|
||||||
"protocol",
|
signal?.aborted === true ? "cancelled" : "protocol",
|
||||||
"Ghidra target snapshot could not be read for load-image verification",
|
"Ghidra target snapshot could not be read for load-image verification",
|
||||||
cause,
|
cause,
|
||||||
),
|
),
|
||||||
@@ -602,6 +690,10 @@ export class GhidraClient {
|
|||||||
this.#lastDiagnostics = this.#diagnostics();
|
this.#lastDiagnostics = this.#diagnostics();
|
||||||
this.#process = undefined;
|
this.#process = undefined;
|
||||||
this.#launch = undefined;
|
this.#launch = undefined;
|
||||||
|
for (const owner of this.#snapshotOwners) {
|
||||||
|
await owner.close();
|
||||||
|
this.#snapshotOwners.delete(owner);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await this.#removeRuntimeRoots();
|
await this.#removeRuntimeRoots();
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { AnalysisOperation } from "../application/AnalysisProvider.js";
|
import type { AnalysisOperation } from "../application/AnalysisProvider.js";
|
||||||
|
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
||||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||||
@@ -12,23 +13,91 @@ import {
|
|||||||
attestGhidraDosComLoadImage,
|
attestGhidraDosComLoadImage,
|
||||||
attestGhidraDosLoadImage,
|
attestGhidraDosLoadImage,
|
||||||
} from "./GhidraLoadImageValues.js";
|
} from "./GhidraLoadImageValues.js";
|
||||||
|
import { type NativeAotPeResult } from "../domain/native/nativeAotPe.js";
|
||||||
|
import type { GhidraTargetSnapshot } from "./GhidraClient.js";
|
||||||
import type { GhidraSessionError } from "./GhidraSessionError.js";
|
import type { GhidraSessionError } from "./GhidraSessionError.js";
|
||||||
|
|
||||||
type LoadImageSnapshotClient = {
|
type LoadImageSnapshotClient = {
|
||||||
readonly readTargetSnapshot?: () => Promise<
|
readonly readTargetSnapshot?: (
|
||||||
Result<Buffer, GhidraSessionError>
|
maximumBytes?: number,
|
||||||
>;
|
signal?: AbortSignal,
|
||||||
|
) => Promise<Result<GhidraTargetSnapshot, GhidraSessionError>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type NativeLoadImageAttestationInput = {
|
||||||
|
readonly target: BinaryTarget;
|
||||||
|
readonly operation: AnalysisOperation;
|
||||||
|
readonly measured: JsonValue;
|
||||||
|
readonly client: LoadImageSnapshotClient;
|
||||||
|
readonly mapSessionError: (failure: GhidraSessionError) => AnalysisError;
|
||||||
|
readonly nativeAotResult?: NativeAotPeResult | undefined;
|
||||||
|
readonly signal?: AbortSignal | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSignalAborted = (signal: AbortSignal | undefined): boolean =>
|
||||||
|
signal?.aborted === true;
|
||||||
|
|
||||||
/** Independently verify measured load-image observations for admitted DOS targets. */
|
/** Independently verify measured load-image observations for admitted DOS targets. */
|
||||||
export const attestGhidraNativeLoadImage = async (
|
export const attestGhidraNativeLoadImage = async (
|
||||||
target: BinaryTarget,
|
input: NativeLoadImageAttestationInput,
|
||||||
operation: AnalysisOperation,
|
|
||||||
measured: JsonValue,
|
|
||||||
client: LoadImageSnapshotClient,
|
|
||||||
mapSessionError: (failure: GhidraSessionError) => AnalysisError,
|
|
||||||
): Promise<Result<JsonValue, AnalysisError>> => {
|
): Promise<Result<JsonValue, AnalysisError>> => {
|
||||||
|
const {
|
||||||
|
target,
|
||||||
|
operation,
|
||||||
|
measured,
|
||||||
|
client,
|
||||||
|
mapSessionError,
|
||||||
|
nativeAotResult,
|
||||||
|
signal,
|
||||||
|
} = input;
|
||||||
|
if (isSignalAborted(signal))
|
||||||
|
return err(new AnalysisCancelledError(operation));
|
||||||
const observations = nativeLoadImageObservationSchema.parse(measured);
|
const observations = nativeLoadImageObservationSchema.parse(measured);
|
||||||
|
if (target.format === "pe") {
|
||||||
|
if (nativeAotResult === undefined)
|
||||||
|
return err(
|
||||||
|
new ProviderAdapterError("ghidra", operation, {
|
||||||
|
diagnostics: {
|
||||||
|
reason:
|
||||||
|
"The Ghidra provider did not produce read-only NativeAOT metadata for this admitted PE snapshot.",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (isSignalAborted(signal))
|
||||||
|
return err(new AnalysisCancelledError(operation));
|
||||||
|
try {
|
||||||
|
return ok(
|
||||||
|
jsonValueSchema.parse(
|
||||||
|
nativeLoadImageSchema.parse({
|
||||||
|
status: "unsupported",
|
||||||
|
reason:
|
||||||
|
"Independent load-image verification currently supports DOS MZ and explicit DOS COM targets only.",
|
||||||
|
observations: {
|
||||||
|
...observations,
|
||||||
|
metadata_recovery: [
|
||||||
|
...(observations.metadata_recovery ?? []),
|
||||||
|
nativeAotResult.summary,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
limitations: [
|
||||||
|
"Load-image verification is not performed for PE targets; NativeAOT metadata observations are derived separately from the exact admitted snapshot.",
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
return err(
|
||||||
|
new ProviderAdapterError("ghidra", operation, {
|
||||||
|
diagnostics: {
|
||||||
|
reason:
|
||||||
|
cause instanceof Error
|
||||||
|
? cause.message
|
||||||
|
: "NativeAOT metadata could not be represented in the load-image result",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (target.format !== "dos-mz" && target.format !== "dos-com") {
|
if (target.format !== "dos-mz" && target.format !== "dos-com") {
|
||||||
return ok(
|
return ok(
|
||||||
jsonValueSchema.parse(
|
jsonValueSchema.parse(
|
||||||
@@ -55,11 +124,20 @@ export const attestGhidraNativeLoadImage = async (
|
|||||||
);
|
);
|
||||||
const snapshot = await client.readTargetSnapshot();
|
const snapshot = await client.readTargetSnapshot();
|
||||||
if (!snapshot.ok) return err(mapSessionError(snapshot.error));
|
if (!snapshot.ok) return err(mapSessionError(snapshot.error));
|
||||||
|
if (snapshot.value.kind !== "captured")
|
||||||
|
return err(
|
||||||
|
new ProviderAdapterError("ghidra", operation, {
|
||||||
|
diagnostics: {
|
||||||
|
reason:
|
||||||
|
"The uncapped DOS load-image snapshot was unexpectedly not captured.",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
const attested = (
|
const attested = (
|
||||||
target.format === "dos-com"
|
target.format === "dos-com"
|
||||||
? attestGhidraDosComLoadImage
|
? attestGhidraDosComLoadImage
|
||||||
: attestGhidraDosLoadImage
|
: attestGhidraDosLoadImage
|
||||||
)(snapshot.value, target.sha256 ?? "", observations);
|
)(snapshot.value.bytes, target.sha256 ?? "", observations);
|
||||||
if (!attested.ok)
|
if (!attested.ok)
|
||||||
return err(
|
return err(
|
||||||
new ProviderAdapterError("ghidra", operation, {
|
new ProviderAdapterError("ghidra", operation, {
|
||||||
|
|||||||
@@ -10,16 +10,22 @@ import { fileURLToPath } from "node:url";
|
|||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { fixtureDosLoadImage } from "./GhidraLoadImage.fixture.js";
|
import { fixtureDosLoadImage } from "./GhidraLoadImage.fixture.js";
|
||||||
|
import {
|
||||||
|
nativeAotPeDigest,
|
||||||
|
nativeAotPeFixture,
|
||||||
|
} from "../../tests/fixtures/nativeaotPe.js";
|
||||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||||
|
|
||||||
import { parseConfig } from "../config/parseConfig.js";
|
import { parseConfig } from "../config/parseConfig.js";
|
||||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||||
import { parseExecutableHeader } from "../domain/binaryTarget.js";
|
import { parseExecutableHeader } from "../domain/binaryTarget.js";
|
||||||
import { GhidraProvider } from "./GhidraProvider.js";
|
import { GhidraProvider } from "./GhidraProvider.js";
|
||||||
|
import type { GhidraTargetSnapshot } from "./GhidraClient.js";
|
||||||
import type { GhidraProviderClientFactory } from "./GhidraProviderClient.js";
|
import type { GhidraProviderClientFactory } from "./GhidraProviderClient.js";
|
||||||
import type { GhidraInstallationHost } from "./GhidraInstallation.js";
|
import type { GhidraInstallationHost } from "./GhidraInstallation.js";
|
||||||
import { GHIDRA_SESSION_CAPABILITIES } from "./GhidraSessionValues.js";
|
import { GHIDRA_SESSION_CAPABILITIES } from "./GhidraSessionValues.js";
|
||||||
import { err, ok } from "../domain/result.js";
|
import { err, ok } from "../domain/result.js";
|
||||||
|
import type { Result } from "../domain/result.js";
|
||||||
import { GhidraSessionError } from "./GhidraSessionError.js";
|
import { GhidraSessionError } from "./GhidraSessionError.js";
|
||||||
import { silentLogger } from "../logger.js";
|
import { silentLogger } from "../logger.js";
|
||||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||||
@@ -219,10 +225,25 @@ describe("Ghidra platform support", () => {
|
|||||||
expect(profile.parameters).toMatchObject({
|
expect(profile.parameters).toMatchObject({
|
||||||
executable_role: target.executableRole,
|
executable_role: target.executableRole,
|
||||||
managed: target.managed,
|
managed: target.managed,
|
||||||
|
native_aot_metadata: {
|
||||||
|
contract_revision: "rtr-9.1-x64-pe-read-only-v1",
|
||||||
|
source_bytes: 128 * 1024 * 1024,
|
||||||
|
work_units: 64 * 1024 * 1024,
|
||||||
|
working_memory_bytes: 64 * 1024 * 1024,
|
||||||
|
report_bytes: 4 * 1024 * 1024,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
digests.add(profile.digest);
|
digests.add(profile.digest);
|
||||||
}
|
}
|
||||||
expect(digests.size).toBe(3);
|
expect(digests.size).toBe(3);
|
||||||
|
const nonPe = await ghidra.resolveAnalysisProfile(
|
||||||
|
executableTarget("elf", "x86_64"),
|
||||||
|
);
|
||||||
|
if (!nonPe.ok || nonPe.value.profile === null)
|
||||||
|
throw new Error("Expected a non-PE analysis profile");
|
||||||
|
expect(nonPe.value.profile.parameters).not.toHaveProperty(
|
||||||
|
"native_aot_metadata",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps Windows annotation mutation unavailable independently of native controls", () => {
|
it("keeps Windows annotation mutation unavailable independently of native controls", () => {
|
||||||
@@ -717,7 +738,8 @@ describe("Ghidra measured load-image projection", () => {
|
|||||||
...(state === "missing-snapshot"
|
...(state === "missing-snapshot"
|
||||||
? {}
|
? {}
|
||||||
: {
|
: {
|
||||||
readTargetSnapshot: () => Promise.resolve(ok(fixture.bytes)),
|
readTargetSnapshot: () =>
|
||||||
|
Promise.resolve(ok({ kind: "captured", bytes: fixture.bytes })),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const ghidra = provider(installationHost(), factory);
|
const ghidra = provider(installationHost(), factory);
|
||||||
@@ -757,6 +779,505 @@ describe("Ghidra measured load-image projection", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns read-only NativeAOT metadata inline and drills down by derived MethodTable address", async () => {
|
||||||
|
const bytes = nativeAotPeFixture();
|
||||||
|
const observations = fixtureDosLoadImage().observation;
|
||||||
|
observations.image_base = "0x140000000";
|
||||||
|
const factory: GhidraProviderClientFactory = () => ({
|
||||||
|
start: () =>
|
||||||
|
Promise.resolve(
|
||||||
|
ok({
|
||||||
|
...sessionInfo(),
|
||||||
|
target: { ...sessionInfo().target, image_base: "0x140000000" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
callTool: (operation) =>
|
||||||
|
Promise.resolve(
|
||||||
|
ok(
|
||||||
|
jsonValueSchema.parse(
|
||||||
|
operation === "inspect_native_load_image"
|
||||||
|
? observations
|
||||||
|
: {
|
||||||
|
status: "unavailable",
|
||||||
|
reason: "No Ghidra DataType is defined at this address.",
|
||||||
|
id: null,
|
||||||
|
name: null,
|
||||||
|
kind: "unavailable",
|
||||||
|
source: "analysis-database",
|
||||||
|
source_archive: null,
|
||||||
|
address: null,
|
||||||
|
size_bytes: null,
|
||||||
|
alignment_bytes: null,
|
||||||
|
packing_enabled: null,
|
||||||
|
referenced_type: null,
|
||||||
|
array_count: null,
|
||||||
|
array_stride_bytes: null,
|
||||||
|
fields: [],
|
||||||
|
members: [],
|
||||||
|
total_fields: 0,
|
||||||
|
truncated: false,
|
||||||
|
limitations: [],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
readTargetSnapshot: () => Promise.resolve(ok({ kind: "captured", bytes })),
|
||||||
|
close: () => Promise.resolve(ok(null)),
|
||||||
|
});
|
||||||
|
const ghidra = provider(installationHost(), factory);
|
||||||
|
const target = { ...peTarget("x86_64"), sha256: nativeAotPeDigest(bytes) };
|
||||||
|
const profile = await ghidra.resolveAnalysisProfile(target);
|
||||||
|
if (!profile.ok || profile.value.profile === null)
|
||||||
|
throw new Error("Expected admitted PE profile");
|
||||||
|
const client = ghidra.createClient(target, profile.value.profile);
|
||||||
|
const image = await client.execute("inspect_native_load_image", {});
|
||||||
|
expect(image).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
value: {
|
||||||
|
result: {
|
||||||
|
status: "unsupported",
|
||||||
|
observations: {
|
||||||
|
metadata_recovery: [
|
||||||
|
{
|
||||||
|
status: "complete",
|
||||||
|
analysis_mode: "read-only-derived-overlay",
|
||||||
|
method_tables: 3,
|
||||||
|
frozen_strings: [{ value: "REA_NATIVEAOT_FROZEN" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rawResult: observations,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const detail = await client.execute("inspect_native_data_type", {
|
||||||
|
address: "0x140002280",
|
||||||
|
});
|
||||||
|
expect(detail).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
value: {
|
||||||
|
result: {
|
||||||
|
status: "unavailable",
|
||||||
|
source: "analysis-database",
|
||||||
|
metadata_recovery: {
|
||||||
|
source: "read-only-derived-overlay",
|
||||||
|
method_table_address: "0x140002280",
|
||||||
|
related_type: { address: "0x140002200", type: null },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await client.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives PE metadata for a direct type-address query without a load-image call", async () => {
|
||||||
|
const bytes = nativeAotPeFixture();
|
||||||
|
const operations: string[] = [];
|
||||||
|
const factory: GhidraProviderClientFactory = () => ({
|
||||||
|
start: () =>
|
||||||
|
Promise.resolve(
|
||||||
|
ok({
|
||||||
|
...sessionInfo(),
|
||||||
|
target: { ...sessionInfo().target, image_base: "0x140000000" },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
callTool: (operation) => {
|
||||||
|
operations.push(operation);
|
||||||
|
return Promise.resolve(
|
||||||
|
ok(
|
||||||
|
jsonValueSchema.parse({
|
||||||
|
status: "unavailable",
|
||||||
|
reason: "No Ghidra DataType is defined at this address.",
|
||||||
|
id: null,
|
||||||
|
name: null,
|
||||||
|
kind: "unavailable",
|
||||||
|
source: "analysis-database",
|
||||||
|
source_archive: null,
|
||||||
|
address: null,
|
||||||
|
size_bytes: null,
|
||||||
|
alignment_bytes: null,
|
||||||
|
packing_enabled: null,
|
||||||
|
referenced_type: null,
|
||||||
|
array_count: null,
|
||||||
|
array_stride_bytes: null,
|
||||||
|
fields: [],
|
||||||
|
members: [],
|
||||||
|
total_fields: 0,
|
||||||
|
truncated: false,
|
||||||
|
limitations: [],
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
readTargetSnapshot: () => Promise.resolve(ok({ kind: "captured", bytes })),
|
||||||
|
close: () => Promise.resolve(ok(null)),
|
||||||
|
});
|
||||||
|
const ghidra = provider(installationHost(), factory);
|
||||||
|
const target = { ...peTarget("x86_64"), sha256: nativeAotPeDigest(bytes) };
|
||||||
|
const profile = await ghidra.resolveAnalysisProfile(target);
|
||||||
|
if (!profile.ok || profile.value.profile === null)
|
||||||
|
throw new Error("Expected admitted PE profile");
|
||||||
|
const client = ghidra.createClient(target, profile.value.profile);
|
||||||
|
const detail = await client.execute("inspect_native_data_type", {
|
||||||
|
address: "0x140002280",
|
||||||
|
});
|
||||||
|
expect(detail).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
value: {
|
||||||
|
result: {
|
||||||
|
metadata_recovery: {
|
||||||
|
source: "read-only-derived-overlay",
|
||||||
|
method_table_address: "0x140002280",
|
||||||
|
related_type: { address: "0x140002200", type: null },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(operations).toEqual(["inspect_native_data_type"]);
|
||||||
|
await client.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shares one session snapshot across callers while isolating cancellation and rejecting a changed base", async () => {
|
||||||
|
const bytes = nativeAotPeFixture();
|
||||||
|
const baseObservation = fixtureDosLoadImage().observation;
|
||||||
|
baseObservation.image_base = "0x140000000";
|
||||||
|
let calls = 0;
|
||||||
|
let snapshotReads = 0;
|
||||||
|
let snapshotSignal: AbortSignal | undefined;
|
||||||
|
let releaseSnapshot:
|
||||||
|
| ((value: Result<GhidraTargetSnapshot, GhidraSessionError>) => void)
|
||||||
|
| undefined;
|
||||||
|
let markSnapshotStarted: (() => void) | undefined;
|
||||||
|
const snapshotStarted = new Promise<void>((resolve) => {
|
||||||
|
markSnapshotStarted = resolve;
|
||||||
|
});
|
||||||
|
const snapshotResult = new Promise<
|
||||||
|
Result<GhidraTargetSnapshot, GhidraSessionError>
|
||||||
|
>((resolve) => {
|
||||||
|
releaseSnapshot = resolve;
|
||||||
|
});
|
||||||
|
const factory: GhidraProviderClientFactory = () => ({
|
||||||
|
start: () => Promise.resolve(ok(sessionInfo())),
|
||||||
|
callTool: (operation) => {
|
||||||
|
calls += 1;
|
||||||
|
const observation = {
|
||||||
|
...baseObservation,
|
||||||
|
source_files: [
|
||||||
|
{ ...baseObservation.source_files[0]!, name: `caller-${calls}` },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
return Promise.resolve(
|
||||||
|
ok(
|
||||||
|
jsonValueSchema.parse(
|
||||||
|
operation === "inspect_native_load_image"
|
||||||
|
? observation
|
||||||
|
: {
|
||||||
|
status: "unavailable",
|
||||||
|
reason: "No Ghidra DataType is defined at this address.",
|
||||||
|
id: null,
|
||||||
|
name: null,
|
||||||
|
kind: "unavailable",
|
||||||
|
source: "analysis-database",
|
||||||
|
source_archive: null,
|
||||||
|
address: null,
|
||||||
|
size_bytes: null,
|
||||||
|
alignment_bytes: null,
|
||||||
|
packing_enabled: null,
|
||||||
|
referenced_type: null,
|
||||||
|
array_count: null,
|
||||||
|
array_stride_bytes: null,
|
||||||
|
fields: [],
|
||||||
|
members: [],
|
||||||
|
total_fields: 0,
|
||||||
|
truncated: false,
|
||||||
|
limitations: [],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
readTargetSnapshot: (_maximumBytes, signal) => {
|
||||||
|
snapshotReads += 1;
|
||||||
|
snapshotSignal = signal;
|
||||||
|
markSnapshotStarted?.();
|
||||||
|
return snapshotResult;
|
||||||
|
},
|
||||||
|
close: () => Promise.resolve(ok(null)),
|
||||||
|
});
|
||||||
|
const ghidra = provider(installationHost(), factory);
|
||||||
|
const target = { ...peTarget("x86_64"), sha256: nativeAotPeDigest(bytes) };
|
||||||
|
const profile = await ghidra.resolveAnalysisProfile(target);
|
||||||
|
if (!profile.ok || profile.value.profile === null)
|
||||||
|
throw new Error("Expected admitted PE profile");
|
||||||
|
const client = ghidra.createClient(target, profile.value.profile);
|
||||||
|
const firstCaller = new AbortController();
|
||||||
|
const secondCaller = new AbortController();
|
||||||
|
const first = client.execute(
|
||||||
|
"inspect_native_load_image",
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
signal: firstCaller.signal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const second = client.execute(
|
||||||
|
"inspect_native_load_image",
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
signal: secondCaller.signal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await snapshotStarted;
|
||||||
|
expect(snapshotReads).toBe(1);
|
||||||
|
expect(snapshotSignal).toBeDefined();
|
||||||
|
expect(snapshotSignal).not.toBe(firstCaller.signal);
|
||||||
|
expect(snapshotSignal).not.toBe(secondCaller.signal);
|
||||||
|
firstCaller.abort();
|
||||||
|
await expect(first).resolves.toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { _tag: "AnalysisCancelledError" },
|
||||||
|
});
|
||||||
|
releaseSnapshot?.(ok({ kind: "captured", bytes }));
|
||||||
|
const secondResult = await second;
|
||||||
|
expect(secondResult).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
value: {
|
||||||
|
rawResult: {
|
||||||
|
source_files: [{ name: "caller-2" }],
|
||||||
|
},
|
||||||
|
result: {
|
||||||
|
observations: {
|
||||||
|
source_files: [{ name: "caller-2" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(snapshotReads).toBe(1);
|
||||||
|
expect(calls).toBe(2);
|
||||||
|
|
||||||
|
baseObservation.image_base = "0x150000000";
|
||||||
|
const conflictingBase = await client.execute("inspect_native_load_image", {});
|
||||||
|
expect(conflictingBase).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
_tag: "ProviderAdapterError",
|
||||||
|
diagnostics: {
|
||||||
|
reason: expect.stringContaining("different loaded image base"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(snapshotReads).toBe(1);
|
||||||
|
await client.close();
|
||||||
|
expect(snapshotSignal?.aborted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries a failed NativeAOT snapshot read without retaining a failed derivation", async () => {
|
||||||
|
const bytes = nativeAotPeFixture();
|
||||||
|
const observations = fixtureDosLoadImage().observation;
|
||||||
|
observations.image_base = "0x140000000";
|
||||||
|
let snapshotReads = 0;
|
||||||
|
const factory: GhidraProviderClientFactory = () => ({
|
||||||
|
start: () => Promise.resolve(ok(sessionInfo())),
|
||||||
|
callTool: () => Promise.resolve(ok(jsonValueSchema.parse(observations))),
|
||||||
|
readTargetSnapshot: () => {
|
||||||
|
snapshotReads += 1;
|
||||||
|
return Promise.resolve(
|
||||||
|
snapshotReads === 1
|
||||||
|
? err(
|
||||||
|
new GhidraSessionError(
|
||||||
|
"process",
|
||||||
|
"temporary snapshot read failure",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ok({ kind: "captured", bytes }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
close: () => Promise.resolve(ok(null)),
|
||||||
|
});
|
||||||
|
const ghidra = provider(installationHost(), factory);
|
||||||
|
const target = { ...peTarget("x86_64"), sha256: nativeAotPeDigest(bytes) };
|
||||||
|
const profile = await ghidra.resolveAnalysisProfile(target);
|
||||||
|
if (!profile.ok || profile.value.profile === null)
|
||||||
|
throw new Error("Expected admitted PE profile");
|
||||||
|
const client = ghidra.createClient(target, profile.value.profile);
|
||||||
|
await expect(
|
||||||
|
client.execute("inspect_native_load_image", {}),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { _tag: "ProviderAdapterError" },
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
client.execute("inspect_native_load_image", {}),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
value: {
|
||||||
|
result: {
|
||||||
|
observations: {
|
||||||
|
metadata_recovery: [{ status: "complete" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(snapshotReads).toBe(2);
|
||||||
|
await client.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects all waiters when the loaded image base changes during derivation", async () => {
|
||||||
|
const bytes = nativeAotPeFixture();
|
||||||
|
const observation = fixtureDosLoadImage().observation;
|
||||||
|
let calls = 0;
|
||||||
|
let resolveSnapshot:
|
||||||
|
| ((value: Result<GhidraTargetSnapshot, GhidraSessionError>) => void)
|
||||||
|
| undefined;
|
||||||
|
let markSnapshotStarted: (() => void) | undefined;
|
||||||
|
const snapshotStarted = new Promise<void>((resolve) => {
|
||||||
|
markSnapshotStarted = resolve;
|
||||||
|
});
|
||||||
|
const snapshot = new Promise<
|
||||||
|
Result<GhidraTargetSnapshot, GhidraSessionError>
|
||||||
|
>((resolve) => {
|
||||||
|
resolveSnapshot = resolve;
|
||||||
|
});
|
||||||
|
const factory: GhidraProviderClientFactory = () => ({
|
||||||
|
start: () => Promise.resolve(ok(sessionInfo())),
|
||||||
|
callTool: () => {
|
||||||
|
calls += 1;
|
||||||
|
return Promise.resolve(
|
||||||
|
ok(
|
||||||
|
jsonValueSchema.parse({
|
||||||
|
...observation,
|
||||||
|
image_base: calls === 1 ? "0x140000000" : "0x150000000",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
readTargetSnapshot: () => {
|
||||||
|
markSnapshotStarted?.();
|
||||||
|
return snapshot;
|
||||||
|
},
|
||||||
|
close: () => Promise.resolve(ok(null)),
|
||||||
|
});
|
||||||
|
const ghidra = provider(installationHost(), factory);
|
||||||
|
const target = { ...peTarget("x86_64"), sha256: nativeAotPeDigest(bytes) };
|
||||||
|
const profile = await ghidra.resolveAnalysisProfile(target);
|
||||||
|
if (!profile.ok || profile.value.profile === null)
|
||||||
|
throw new Error("Expected admitted PE profile");
|
||||||
|
const client = ghidra.createClient(target, profile.value.profile);
|
||||||
|
const originalBase = client.execute("inspect_native_load_image", {});
|
||||||
|
await snapshotStarted;
|
||||||
|
const changedBase = await client.execute("inspect_native_load_image", {});
|
||||||
|
expect(changedBase).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
_tag: "ProviderAdapterError",
|
||||||
|
diagnostics: {
|
||||||
|
observed_image_base: "0x150000000",
|
||||||
|
measured_observation: { image_base: "0x150000000" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
resolveSnapshot?.(ok({ kind: "captured", bytes }));
|
||||||
|
await expect(originalBase).resolves.toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
_tag: "ProviderAdapterError",
|
||||||
|
diagnostics: {
|
||||||
|
reason: expect.stringContaining("conflicting loaded image bases"),
|
||||||
|
measured_observation: { image_base: "0x140000000" },
|
||||||
|
conflicting_observation: { image_base: "0x150000000" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(calls).toBe(2);
|
||||||
|
await client.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels an in-flight NativeAOT waiter when its owning session closes", async () => {
|
||||||
|
const bytes = nativeAotPeFixture();
|
||||||
|
const observations = fixtureDosLoadImage().observation;
|
||||||
|
observations.image_base = "0x140000000";
|
||||||
|
let resolveSnapshot:
|
||||||
|
| ((value: Result<GhidraTargetSnapshot, GhidraSessionError>) => void)
|
||||||
|
| undefined;
|
||||||
|
let snapshotSignal: AbortSignal | undefined;
|
||||||
|
let markSnapshotStarted: (() => void) | undefined;
|
||||||
|
const snapshotStarted = new Promise<void>((resolve) => {
|
||||||
|
markSnapshotStarted = resolve;
|
||||||
|
});
|
||||||
|
const snapshot = new Promise<
|
||||||
|
Result<GhidraTargetSnapshot, GhidraSessionError>
|
||||||
|
>((resolve) => {
|
||||||
|
resolveSnapshot = resolve;
|
||||||
|
});
|
||||||
|
const factory: GhidraProviderClientFactory = () => ({
|
||||||
|
start: () => Promise.resolve(ok(sessionInfo())),
|
||||||
|
callTool: () => Promise.resolve(ok(jsonValueSchema.parse(observations))),
|
||||||
|
readTargetSnapshot: (_maximumBytes, signal) => {
|
||||||
|
snapshotSignal = signal;
|
||||||
|
markSnapshotStarted?.();
|
||||||
|
return snapshot;
|
||||||
|
},
|
||||||
|
close: () => Promise.resolve(ok(null)),
|
||||||
|
});
|
||||||
|
const ghidra = provider(installationHost(), factory);
|
||||||
|
const target = { ...peTarget("x86_64"), sha256: nativeAotPeDigest(bytes) };
|
||||||
|
const profile = await ghidra.resolveAnalysisProfile(target);
|
||||||
|
if (!profile.ok || profile.value.profile === null)
|
||||||
|
throw new Error("Expected admitted PE profile");
|
||||||
|
const client = ghidra.createClient(target, profile.value.profile);
|
||||||
|
const pending = client.execute("inspect_native_load_image", {});
|
||||||
|
await snapshotStarted;
|
||||||
|
await client.close();
|
||||||
|
expect(snapshotSignal?.aborted).toBe(true);
|
||||||
|
resolveSnapshot?.(ok({ kind: "captured", bytes }));
|
||||||
|
await expect(pending).resolves.toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { _tag: "AnalysisCancelledError" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Ghidra NativeAOT snapshot budget", () => {
|
||||||
|
it("reports snapshot-capacity omissions inline through the load-image result", async () => {
|
||||||
|
const observations = fixtureDosLoadImage().observation;
|
||||||
|
const target = peTarget("x86_64");
|
||||||
|
const factory: GhidraProviderClientFactory = () => ({
|
||||||
|
start: () => Promise.resolve(ok(sessionInfo())),
|
||||||
|
callTool: () => Promise.resolve(ok(jsonValueSchema.parse(observations))),
|
||||||
|
readTargetSnapshot: () =>
|
||||||
|
Promise.resolve(
|
||||||
|
ok({
|
||||||
|
kind: "over-capacity",
|
||||||
|
sourceBytesAtLeast: 128 * 1024 * 1024 + 1,
|
||||||
|
maximumBytes: 128 * 1024 * 1024,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
close: () => Promise.resolve(ok(null)),
|
||||||
|
});
|
||||||
|
const ghidra = provider(installationHost(), factory);
|
||||||
|
const profile = await ghidra.resolveAnalysisProfile(target);
|
||||||
|
if (!profile.ok || profile.value.profile === null)
|
||||||
|
throw new Error("Expected admitted PE profile");
|
||||||
|
const client = ghidra.createClient(target, profile.value.profile);
|
||||||
|
const image = await client.execute("inspect_native_load_image", {});
|
||||||
|
expect(image).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
value: {
|
||||||
|
result: {
|
||||||
|
observations: {
|
||||||
|
metadata_recovery: [
|
||||||
|
{
|
||||||
|
status: "partial",
|
||||||
|
truncated: true,
|
||||||
|
coverage: { truncation_reason: "source-byte-budget" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await client.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const sessionInfo = () => ({
|
const sessionInfo = () => ({
|
||||||
name: "REA Ghidra bridge" as const,
|
name: "REA Ghidra bridge" as const,
|
||||||
run_id: "11111111-1111-4111-8111-111111111111",
|
run_id: "11111111-1111-4111-8111-111111111111",
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
type AnalysisOperation,
|
type AnalysisOperation,
|
||||||
} from "../application/AnalysisProvider.js";
|
} from "../application/AnalysisProvider.js";
|
||||||
import type { AppConfig } from "../config/types.js";
|
import type { AppConfig } from "../config/types.js";
|
||||||
import type { JsonValue } from "../domain/jsonValue.js";
|
|
||||||
import {
|
import {
|
||||||
ghidraSeedCommitmentSchema,
|
ghidraSeedCommitmentSchema,
|
||||||
ghidraSeedFailure,
|
ghidraSeedFailure,
|
||||||
@@ -30,6 +29,7 @@ import {
|
|||||||
AnalysisTimeoutError,
|
AnalysisTimeoutError,
|
||||||
} from "../domain/analysisErrorCore.js";
|
} from "../domain/analysisErrorCore.js";
|
||||||
import { type AnalysisError } from "../domain/analysisErrorBase.js";
|
import { type AnalysisError } from "../domain/analysisErrorBase.js";
|
||||||
|
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||||
import { err, ok, type Result } from "../domain/result.js";
|
import { err, ok, type Result } from "../domain/result.js";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
@@ -52,6 +52,18 @@ import {
|
|||||||
import { unverifiedGhidraBuildLimitation } from "./GhidraInstallationPolicy.js";
|
import { unverifiedGhidraBuildLimitation } from "./GhidraInstallationPolicy.js";
|
||||||
import { GhidraHeadlessLauncher } from "./GhidraLauncher.js";
|
import { GhidraHeadlessLauncher } from "./GhidraLauncher.js";
|
||||||
import { attestGhidraNativeLoadImage } from "./GhidraLoadImageAttest.js";
|
import { attestGhidraNativeLoadImage } from "./GhidraLoadImageAttest.js";
|
||||||
|
import {
|
||||||
|
NATIVE_AOT_MAX_REPORT_BYTES,
|
||||||
|
NATIVE_AOT_MAX_SOURCE_BYTES,
|
||||||
|
nativeAotSnapshotOverCapacity,
|
||||||
|
parseNativeAotPe,
|
||||||
|
type NativeAotPeResult,
|
||||||
|
} from "../domain/native/nativeAotPe.js";
|
||||||
|
import {
|
||||||
|
ABORTED,
|
||||||
|
waitForAbortable,
|
||||||
|
} from "../application/binary/AbortablePromise.js";
|
||||||
|
import { nativeLoadImageObservationSchema } from "../domain/native/nativeLoadImage.js";
|
||||||
import {
|
import {
|
||||||
GHIDRA_PROVIDER_IDENTITY,
|
GHIDRA_PROVIDER_IDENTITY,
|
||||||
healthLimitations,
|
healthLimitations,
|
||||||
@@ -77,6 +89,250 @@ export type GhidraProviderClientFactory = (
|
|||||||
) => Pick<GhidraClient, "start" | "callTool" | "close"> &
|
) => Pick<GhidraClient, "start" | "callTool" | "close"> &
|
||||||
Partial<Pick<GhidraClient, "runtimeLineage" | "readTargetSnapshot">>;
|
Partial<Pick<GhidraClient, "runtimeLineage" | "readTargetSnapshot">>;
|
||||||
|
|
||||||
|
type GhidraProviderClientInstance = ReturnType<GhidraProviderClientFactory>;
|
||||||
|
|
||||||
|
type NativeAotSnapshotOwner = {
|
||||||
|
readonly derive: (
|
||||||
|
imageBase: string,
|
||||||
|
operation: AnalysisOperation,
|
||||||
|
callerSignal: AbortSignal | undefined,
|
||||||
|
observations: JsonValue,
|
||||||
|
) => Promise<Result<NativeAotPeResult, AnalysisError>>;
|
||||||
|
readonly readTypeDetail: (
|
||||||
|
address: string,
|
||||||
|
) => ReturnType<NativeAotPeResult["readTypeDetail"]>;
|
||||||
|
readonly close: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Own one session's captured PE snapshot and immutable NativeAOT derivation. */
|
||||||
|
const createNativeAotSnapshotOwner = (input: {
|
||||||
|
readonly client: GhidraProviderClientInstance;
|
||||||
|
readonly target: BinaryTarget;
|
||||||
|
readonly startupTimeoutMs: number;
|
||||||
|
}): NativeAotSnapshotOwner => {
|
||||||
|
const { client, target, startupTimeoutMs } = input;
|
||||||
|
const controller = new AbortController();
|
||||||
|
let identity: string | undefined;
|
||||||
|
let result: NativeAotPeResult | undefined;
|
||||||
|
let pending:
|
||||||
|
| {
|
||||||
|
readonly identity: string;
|
||||||
|
readonly promise: Promise<Result<NativeAotPeResult, AnalysisError>>;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
let closed = false;
|
||||||
|
let identityConflict = false;
|
||||||
|
let identityConflictObservation:
|
||||||
|
| { readonly imageBase: string; readonly observations: JsonValue }
|
||||||
|
| undefined;
|
||||||
|
const identityError = (
|
||||||
|
operation: AnalysisOperation,
|
||||||
|
reason: string,
|
||||||
|
observedBase: string,
|
||||||
|
observations: JsonValue,
|
||||||
|
): AnalysisError =>
|
||||||
|
new ProviderAdapterError("ghidra", operation, {
|
||||||
|
diagnostics: {
|
||||||
|
reason,
|
||||||
|
artifact_sha256: target.sha256,
|
||||||
|
bound_image_base:
|
||||||
|
identity === undefined
|
||||||
|
? null
|
||||||
|
: `0x${identity.slice(target.sha256.length + 1)}`,
|
||||||
|
observed_image_base: observedBase,
|
||||||
|
measured_observation: observations,
|
||||||
|
conflicting_image_base: identityConflictObservation?.imageBase ?? null,
|
||||||
|
conflicting_observation:
|
||||||
|
identityConflictObservation?.observations ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const identityConflictFailure = (
|
||||||
|
operation: AnalysisOperation,
|
||||||
|
observedBase: string,
|
||||||
|
observations: JsonValue,
|
||||||
|
): Result<NativeAotPeResult, AnalysisError> => {
|
||||||
|
return err(
|
||||||
|
identityError(
|
||||||
|
operation,
|
||||||
|
"The Ghidra session reported conflicting loaded image bases; reopen the analysis session to establish a new identity.",
|
||||||
|
observedBase,
|
||||||
|
observations,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const derive = async (
|
||||||
|
imageBase: string,
|
||||||
|
operation: AnalysisOperation,
|
||||||
|
callerSignal: AbortSignal | undefined,
|
||||||
|
observations: JsonValue,
|
||||||
|
): Promise<Result<NativeAotPeResult, AnalysisError>> => {
|
||||||
|
if (callerSignal?.aborted || closed)
|
||||||
|
return err(new AnalysisCancelledError(operation));
|
||||||
|
if (identityConflict)
|
||||||
|
return identityConflictFailure(operation, imageBase, observations);
|
||||||
|
if (controller.signal.aborted)
|
||||||
|
return err(new AnalysisCancelledError(operation));
|
||||||
|
let normalizedBase: string;
|
||||||
|
try {
|
||||||
|
normalizedBase = BigInt(imageBase).toString(16);
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
return err(
|
||||||
|
new ProviderAdapterError("ghidra", operation, {
|
||||||
|
diagnostics: {
|
||||||
|
reason:
|
||||||
|
"Ghidra returned an invalid loaded image base for NativeAOT identity binding.",
|
||||||
|
artifact_sha256: target.sha256,
|
||||||
|
observed_image_base: imageBase,
|
||||||
|
measured_observation: observations,
|
||||||
|
},
|
||||||
|
cause,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const key = `${target.sha256}:${normalizedBase}`;
|
||||||
|
if (identity !== undefined && identity !== key) {
|
||||||
|
identityConflict = true;
|
||||||
|
identityConflictObservation = { imageBase, observations };
|
||||||
|
result = undefined;
|
||||||
|
controller.abort();
|
||||||
|
return err(
|
||||||
|
identityError(
|
||||||
|
operation,
|
||||||
|
"The Ghidra session reported a different loaded image base after NativeAOT metadata was bound to its captured snapshot; reopen the analysis session to establish a new identity.",
|
||||||
|
imageBase,
|
||||||
|
observations,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
identity ??= key;
|
||||||
|
if (result !== undefined) {
|
||||||
|
const settled = await waitForAbortable(
|
||||||
|
Promise.resolve(ok(result)),
|
||||||
|
callerSignal,
|
||||||
|
);
|
||||||
|
if (closed) return err(new AnalysisCancelledError(operation));
|
||||||
|
if (identityConflict)
|
||||||
|
return identityConflictFailure(operation, imageBase, observations);
|
||||||
|
if (controller.signal.aborted)
|
||||||
|
return err(new AnalysisCancelledError(operation));
|
||||||
|
return settled === ABORTED
|
||||||
|
? err(new AnalysisCancelledError(operation))
|
||||||
|
: settled;
|
||||||
|
}
|
||||||
|
if (pending !== undefined && pending.identity !== key)
|
||||||
|
return err(
|
||||||
|
identityError(
|
||||||
|
operation,
|
||||||
|
"A NativeAOT snapshot derivation is already bound to a different loaded image base.",
|
||||||
|
imageBase,
|
||||||
|
observations,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (pending === undefined) {
|
||||||
|
const promise: Promise<Result<NativeAotPeResult, AnalysisError>> =
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
if (client.readTargetSnapshot === undefined)
|
||||||
|
return err(
|
||||||
|
new ProviderAdapterError("ghidra", operation, {
|
||||||
|
diagnostics: {
|
||||||
|
reason:
|
||||||
|
"The Ghidra client does not expose its immutable target snapshot for NativeAOT metadata inspection.",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const snapshot = await client.readTargetSnapshot(
|
||||||
|
NATIVE_AOT_MAX_SOURCE_BYTES,
|
||||||
|
controller.signal,
|
||||||
|
);
|
||||||
|
if (!snapshot.ok)
|
||||||
|
return err(
|
||||||
|
projectSessionError(
|
||||||
|
operation,
|
||||||
|
snapshot.error,
|
||||||
|
startupTimeoutMs,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (closed) return err(new AnalysisCancelledError(operation));
|
||||||
|
if (identityConflict)
|
||||||
|
return identityConflictFailure(
|
||||||
|
operation,
|
||||||
|
imageBase,
|
||||||
|
observations,
|
||||||
|
);
|
||||||
|
if (controller.signal.aborted)
|
||||||
|
return err(new AnalysisCancelledError(operation));
|
||||||
|
const parsed =
|
||||||
|
snapshot.value.kind === "over-capacity"
|
||||||
|
? {
|
||||||
|
summary: nativeAotSnapshotOverCapacity(
|
||||||
|
target.sha256,
|
||||||
|
snapshot.value.sourceBytesAtLeast,
|
||||||
|
NATIVE_AOT_MAX_REPORT_BYTES,
|
||||||
|
),
|
||||||
|
readTypeDetail: () => undefined,
|
||||||
|
}
|
||||||
|
: parseNativeAotPe(
|
||||||
|
snapshot.value.bytes,
|
||||||
|
target.sha256,
|
||||||
|
NATIVE_AOT_MAX_REPORT_BYTES,
|
||||||
|
imageBase,
|
||||||
|
);
|
||||||
|
if (closed) return err(new AnalysisCancelledError(operation));
|
||||||
|
if (identityConflict)
|
||||||
|
return identityConflictFailure(
|
||||||
|
operation,
|
||||||
|
imageBase,
|
||||||
|
observations,
|
||||||
|
);
|
||||||
|
if (controller.signal.aborted)
|
||||||
|
return err(new AnalysisCancelledError(operation));
|
||||||
|
return ok(parsed);
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
return err(
|
||||||
|
new ProviderAdapterError("ghidra", operation, {
|
||||||
|
diagnostics: {
|
||||||
|
reason:
|
||||||
|
cause instanceof Error
|
||||||
|
? cause.message
|
||||||
|
: "NativeAOT metadata could not be derived from the captured target snapshot.",
|
||||||
|
},
|
||||||
|
cause,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
const current = { identity: key, promise };
|
||||||
|
pending = current;
|
||||||
|
void promise.then((settled) => {
|
||||||
|
if (pending !== current) return;
|
||||||
|
pending = undefined;
|
||||||
|
if (settled.ok && !closed && !identityConflict) result = settled.value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const settled = await waitForAbortable(pending.promise, callerSignal);
|
||||||
|
if (closed) return err(new AnalysisCancelledError(operation));
|
||||||
|
if (identityConflict)
|
||||||
|
return identityConflictFailure(operation, imageBase, observations);
|
||||||
|
if (controller.signal.aborted)
|
||||||
|
return err(new AnalysisCancelledError(operation));
|
||||||
|
return settled === ABORTED
|
||||||
|
? err(new AnalysisCancelledError(operation))
|
||||||
|
: settled;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
derive,
|
||||||
|
readTypeDetail: (address) =>
|
||||||
|
result?.readTypeDetail(address, NATIVE_AOT_MAX_REPORT_BYTES),
|
||||||
|
close: () => {
|
||||||
|
closed = true;
|
||||||
|
controller.abort();
|
||||||
|
result = undefined;
|
||||||
|
pending = undefined;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
/** Build one AnalysisClient for an admitted Ghidra target and profile. */
|
/** Build one AnalysisClient for an admitted Ghidra target and profile. */
|
||||||
export const createGhidraProviderClient = (input: {
|
export const createGhidraProviderClient = (input: {
|
||||||
readonly config: AppConfig;
|
readonly config: AppConfig;
|
||||||
@@ -258,6 +514,14 @@ export const createGhidraProviderClient = (input: {
|
|||||||
...seedLimitations,
|
...seedLimitations,
|
||||||
...(releaseLimitation === undefined ? [] : [releaseLimitation]),
|
...(releaseLimitation === undefined ? [] : [releaseLimitation]),
|
||||||
];
|
];
|
||||||
|
const nativeAotOwner =
|
||||||
|
target.format === "pe"
|
||||||
|
? createNativeAotSnapshotOwner({
|
||||||
|
client,
|
||||||
|
target,
|
||||||
|
startupTimeoutMs: config.ghidraStartupTimeoutMs,
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
return {
|
return {
|
||||||
execute: async (operation, parameters, options) => {
|
execute: async (operation, parameters, options) => {
|
||||||
if (extensionFailure !== undefined) return err(extensionFailure);
|
if (extensionFailure !== undefined) return err(extensionFailure);
|
||||||
@@ -334,19 +598,83 @@ export const createGhidraProviderClient = (input: {
|
|||||||
if (!result.ok) return result;
|
if (!result.ok) return result;
|
||||||
normalized = result.value;
|
normalized = result.value;
|
||||||
}
|
}
|
||||||
|
if (operation === "inspect_native_data_type") {
|
||||||
|
const address = input.value.address;
|
||||||
|
if (typeof address === "string") {
|
||||||
|
if (target.format === "pe" && nativeAotOwner !== undefined) {
|
||||||
|
const started = await client.start(options?.signal);
|
||||||
|
if (!started.ok)
|
||||||
|
return err(
|
||||||
|
projectSessionError(
|
||||||
|
operation,
|
||||||
|
started.error,
|
||||||
|
config.ghidraStartupTimeoutMs,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const derived = await nativeAotOwner.derive(
|
||||||
|
started.value.target.image_base,
|
||||||
|
operation,
|
||||||
|
options?.signal,
|
||||||
|
normalized,
|
||||||
|
);
|
||||||
|
if (!derived.ok) return derived;
|
||||||
|
}
|
||||||
|
let metadata:
|
||||||
|
| ReturnType<NativeAotPeResult["readTypeDetail"]>
|
||||||
|
| undefined;
|
||||||
|
metadata = nativeAotOwner?.readTypeDetail(address);
|
||||||
|
if (
|
||||||
|
metadata !== undefined &&
|
||||||
|
normalized !== null &&
|
||||||
|
typeof normalized === "object" &&
|
||||||
|
!Array.isArray(normalized)
|
||||||
|
) {
|
||||||
|
const nativeResult = normalized as Record<string, JsonValue>;
|
||||||
|
if (nativeResult.metadata_recovery === undefined)
|
||||||
|
normalized = jsonValueSchema.parse({
|
||||||
|
...nativeResult,
|
||||||
|
metadata_recovery: metadata,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (operation === "inspect_native_load_image") {
|
if (operation === "inspect_native_load_image") {
|
||||||
const attested = await attestGhidraNativeLoadImage(
|
let derived: NativeAotPeResult | undefined;
|
||||||
|
if (target.format === "pe") {
|
||||||
|
const observations =
|
||||||
|
nativeLoadImageObservationSchema.parse(normalized);
|
||||||
|
if (nativeAotOwner === undefined)
|
||||||
|
return err(
|
||||||
|
new ProviderAdapterError("ghidra", operation, {
|
||||||
|
diagnostics: {
|
||||||
|
reason:
|
||||||
|
"The admitted PE session has no read-only NativeAOT snapshot owner.",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const result = await nativeAotOwner.derive(
|
||||||
|
observations.image_base,
|
||||||
|
operation,
|
||||||
|
options?.signal,
|
||||||
|
normalized,
|
||||||
|
);
|
||||||
|
if (!result.ok) return result;
|
||||||
|
derived = result.value;
|
||||||
|
}
|
||||||
|
const attested = await attestGhidraNativeLoadImage({
|
||||||
target,
|
target,
|
||||||
operation,
|
operation,
|
||||||
normalized,
|
measured: normalized,
|
||||||
client,
|
client,
|
||||||
(failure) =>
|
mapSessionError: (failure) =>
|
||||||
projectSessionError(
|
projectSessionError(
|
||||||
operation,
|
operation,
|
||||||
failure,
|
failure,
|
||||||
config.ghidraStartupTimeoutMs,
|
config.ghidraStartupTimeoutMs,
|
||||||
),
|
),
|
||||||
);
|
nativeAotResult: derived,
|
||||||
|
signal: options?.signal,
|
||||||
|
});
|
||||||
if (!attested.ok) return attested;
|
if (!attested.ok) return attested;
|
||||||
normalized = attested.value;
|
normalized = attested.value;
|
||||||
}
|
}
|
||||||
@@ -368,7 +696,10 @@ export const createGhidraProviderClient = (input: {
|
|||||||
? []
|
? []
|
||||||
: [{ provider: committedProfile.provider, observation }];
|
: [{ provider: committedProfile.provider, observation }];
|
||||||
},
|
},
|
||||||
close: () => client.close(),
|
close: async () => {
|
||||||
|
nativeAotOwner?.close();
|
||||||
|
return client.close();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { constants } from "node:fs";
|
import { constants } from "node:fs";
|
||||||
import { lstat, open, opendir, writeFile } from "node:fs/promises";
|
import { lstat, mkdir, open, opendir, writeFile } from "node:fs/promises";
|
||||||
import { isAbsolute, join } from "node:path";
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||||
import { TextDecoder } from "node:util";
|
import { TextDecoder } from "node:util";
|
||||||
|
import { normalizeArtifactPath } from "../../artifacts/ArtifactPaths.js";
|
||||||
import {
|
import {
|
||||||
AnalysisCancelledError,
|
AnalysisCancelledError,
|
||||||
AnalysisInputError,
|
AnalysisInputError,
|
||||||
@@ -89,12 +90,22 @@ export const readRecoveryFile = async (
|
|||||||
return Buffer.concat(chunks, result.bytes);
|
return Buffer.concat(chunks, result.bytes);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Snapshot one caller-selected UTF-8 script before invoking the engine. */
|
export type RecoveryInputSnapshot = {
|
||||||
export const snapshotRecoveryInput = async (
|
path: string;
|
||||||
|
snapshot_path: string;
|
||||||
|
relative_path: string;
|
||||||
|
sha256: string;
|
||||||
|
bytes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Snapshot one script within the remaining operation-wide input budget. */
|
||||||
|
const snapshotRecoveryInput = async (
|
||||||
path: string,
|
path: string,
|
||||||
snapshotPath: string,
|
snapshotPath: string,
|
||||||
|
relativePath: string,
|
||||||
|
maximum: number,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
) => {
|
): Promise<RecoveryInputSnapshot> => {
|
||||||
if (!isAbsolute(path))
|
if (!isAbsolute(path))
|
||||||
throw recoveryInputFailure(
|
throw recoveryInputFailure(
|
||||||
"path",
|
"path",
|
||||||
@@ -102,7 +113,7 @@ export const snapshotRecoveryInput = async (
|
|||||||
);
|
);
|
||||||
let bytes: Buffer;
|
let bytes: Buffer;
|
||||||
try {
|
try {
|
||||||
bytes = await readRecoveryFile(path, RECOVERY_LIMITS.inputBytes, signal);
|
bytes = await readRecoveryFile(path, maximum, signal);
|
||||||
new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
if (cause instanceof AnalysisCancelledError) throw cause;
|
if (cause instanceof AnalysisCancelledError) throw cause;
|
||||||
@@ -112,15 +123,78 @@ export const snapshotRecoveryInput = async (
|
|||||||
cause,
|
cause,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
await mkdir(dirname(snapshotPath), { recursive: true, mode: 0o700 });
|
||||||
await writeFile(snapshotPath, bytes, { flag: "wx", mode: 0o600 });
|
await writeFile(snapshotPath, bytes, { flag: "wx", mode: 0o600 });
|
||||||
return {
|
return {
|
||||||
path,
|
path,
|
||||||
snapshot_path: snapshotPath,
|
snapshot_path: snapshotPath,
|
||||||
|
relative_path: relativePath,
|
||||||
sha256: recoveryDigest(bytes),
|
sha256: recoveryDigest(bytes),
|
||||||
bytes: bytes.length,
|
bytes: bytes.length,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Preserve each selected file and its relative input layout without concatenation. */
|
||||||
|
export const snapshotRecoveryInputs = async (
|
||||||
|
paths: readonly [string, ...string[]],
|
||||||
|
directory: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<readonly [RecoveryInputSnapshot, ...RecoveryInputSnapshot[]]> => {
|
||||||
|
let commonDirectory = dirname(resolve(paths[0]));
|
||||||
|
for (const path of paths) {
|
||||||
|
while (true) {
|
||||||
|
const fromCommon = relative(commonDirectory, resolve(path));
|
||||||
|
if (
|
||||||
|
fromCommon !== ".." &&
|
||||||
|
!fromCommon.startsWith(`..${sep}`) &&
|
||||||
|
!isAbsolute(fromCommon)
|
||||||
|
)
|
||||||
|
break;
|
||||||
|
const parent = dirname(commonDirectory);
|
||||||
|
if (parent === commonDirectory)
|
||||||
|
throw recoveryInputFailure(
|
||||||
|
"additional_paths",
|
||||||
|
"Selected files must share a filesystem root",
|
||||||
|
);
|
||||||
|
commonDirectory = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const snapshots: RecoveryInputSnapshot[] = [];
|
||||||
|
let remaining = RECOVERY_LIMITS.inputBytes;
|
||||||
|
for (const path of paths) {
|
||||||
|
const relativePath =
|
||||||
|
paths.length === 1
|
||||||
|
? "bundle.js"
|
||||||
|
: relative(commonDirectory, resolve(path)).split(sep).join("/");
|
||||||
|
try {
|
||||||
|
if (normalizeArtifactPath(relativePath) !== relativePath)
|
||||||
|
throw new TypeError(
|
||||||
|
"Input names must already use NFC Unicode spelling",
|
||||||
|
);
|
||||||
|
} catch (cause: unknown) {
|
||||||
|
throw recoveryInputFailure(
|
||||||
|
"path",
|
||||||
|
`Cannot preserve selected input path in published artifacts: ${path}: ${recoveryFailureMessage(cause)}`,
|
||||||
|
cause,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const snapshot = await snapshotRecoveryInput(
|
||||||
|
path,
|
||||||
|
join(directory, relativePath),
|
||||||
|
relativePath,
|
||||||
|
remaining,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
snapshots.push(snapshot);
|
||||||
|
remaining -= snapshot.bytes;
|
||||||
|
}
|
||||||
|
// The selected-input tuple guarantees a primary snapshot.
|
||||||
|
const [primary, ...additional] = snapshots;
|
||||||
|
if (primary === undefined)
|
||||||
|
throw recoveryInputFailure("path", "Select a JavaScript input");
|
||||||
|
return [primary, ...additional];
|
||||||
|
};
|
||||||
|
|
||||||
/** Inventory staging without following symlinks or publishing special files. */
|
/** Inventory staging without following symlinks or publishing special files. */
|
||||||
export const inventoryRecoveryFiles = async (
|
export const inventoryRecoveryFiles = async (
|
||||||
root: string,
|
root: string,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { constants } from "node:fs";
|
import { constants } from "node:fs";
|
||||||
import { access, realpath } from "node:fs/promises";
|
import { access, realpath, stat } from "node:fs/promises";
|
||||||
import { isAbsolute } from "node:path";
|
import { isAbsolute } from "node:path";
|
||||||
import {
|
import {
|
||||||
AnalysisCancelledError,
|
AnalysisCancelledError,
|
||||||
@@ -87,6 +87,8 @@ export const resolveWakaruCommand = async (
|
|||||||
if (!isAbsolute(limiter))
|
if (!isAbsolute(limiter))
|
||||||
throw new TypeError("REA_JAVASCRIPT_PRLIMIT_COMMAND must be absolute");
|
throw new TypeError("REA_JAVASCRIPT_PRLIMIT_COMMAND must be absolute");
|
||||||
await access(limiter, constants.X_OK);
|
await access(limiter, constants.X_OK);
|
||||||
|
if (!(await stat(limiter)).isFile())
|
||||||
|
throw new TypeError(`Resource limiter is not a regular file: ${limiter}`);
|
||||||
return { command, limiter, ...fingerprint };
|
return { command, limiter, ...fingerprint };
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
const reason = `Configured Wakaru or util-linux prlimit is unavailable: ${recoveryFailureMessage(cause)}`;
|
const reason = `Configured Wakaru or util-linux prlimit is unavailable: ${recoveryFailureMessage(cause)}`;
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ import {
|
|||||||
fingerprintRecoveryFile,
|
fingerprintRecoveryFile,
|
||||||
inventoryRecoveryFiles,
|
inventoryRecoveryFiles,
|
||||||
readRecoveryFile,
|
readRecoveryFile,
|
||||||
recoveryDigest,
|
type snapshotRecoveryInputs,
|
||||||
type snapshotRecoveryInput,
|
|
||||||
} from "./RecoveryFiles.js";
|
} from "./RecoveryFiles.js";
|
||||||
import {
|
import {
|
||||||
runWakaruCommand,
|
runWakaruCommand,
|
||||||
@@ -38,7 +37,7 @@ type RecoveryWorkspace = {
|
|||||||
root: string;
|
root: string;
|
||||||
tree: SafeOutputTree;
|
tree: SafeOutputTree;
|
||||||
input: JavaScriptRecoveryInput;
|
input: JavaScriptRecoveryInput;
|
||||||
source: Awaited<ReturnType<typeof snapshotRecoveryInput>>;
|
sources: Awaited<ReturnType<typeof snapshotRecoveryInputs>>;
|
||||||
engine: Awaited<ReturnType<typeof resolveWakaruCommand>>;
|
engine: Awaited<ReturnType<typeof resolveWakaruCommand>>;
|
||||||
environment: Readonly<Record<string, string | undefined>>;
|
environment: Readonly<Record<string, string | undefined>>;
|
||||||
launcher: WakaruLauncher | undefined;
|
launcher: WakaruLauncher | undefined;
|
||||||
@@ -48,7 +47,7 @@ type RecoveryWorkspace = {
|
|||||||
|
|
||||||
/** Consume the pinned producer representation in a private workspace. */
|
/** Consume the pinned producer representation in a private workspace. */
|
||||||
const executeWakaruRecovery = async (context: RecoveryWorkspace) => {
|
const executeWakaruRecovery = async (context: RecoveryWorkspace) => {
|
||||||
const { root, input, source, engine, environment, launcher, options } =
|
const { root, input, sources, engine, environment, launcher, options } =
|
||||||
context;
|
context;
|
||||||
const staging = join(root, "modules");
|
const staging = join(root, "modules");
|
||||||
const commandContext = {
|
const commandContext = {
|
||||||
@@ -81,7 +80,7 @@ const executeWakaruRecovery = async (context: RecoveryWorkspace) => {
|
|||||||
...commandContext,
|
...commandContext,
|
||||||
staging,
|
staging,
|
||||||
arguments: [
|
arguments: [
|
||||||
source.snapshot_path,
|
...sources.map((source) => source.snapshot_path),
|
||||||
`--unpack=${MODES[input.extraction_mode]}`,
|
`--unpack=${MODES[input.extraction_mode]}`,
|
||||||
`--level=${input.rewrite_level}`,
|
`--level=${input.rewrite_level}`,
|
||||||
"--provenance",
|
"--provenance",
|
||||||
@@ -92,16 +91,20 @@ const executeWakaruRecovery = async (context: RecoveryWorkspace) => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
const files = await inventoryRecoveryFiles(staging, options?.signal);
|
const files = await inventoryRecoveryFiles(staging, options?.signal);
|
||||||
const snapshot = await readRecoveryFile(
|
const snapshots = new Map<string, Buffer>();
|
||||||
source.snapshot_path,
|
for (const source of sources)
|
||||||
RECOVERY_LIMITS.inputBytes,
|
snapshots.set(
|
||||||
options?.signal,
|
source.snapshot_path,
|
||||||
);
|
await readRecoveryFile(
|
||||||
|
source.snapshot_path,
|
||||||
|
source.bytes,
|
||||||
|
options?.signal,
|
||||||
|
),
|
||||||
|
);
|
||||||
const parsed = await parseWakaruReports({
|
const parsed = await parseWakaruReports({
|
||||||
execution: run,
|
execution: run,
|
||||||
provenancePath: join(staging, "provenance.json"),
|
provenancePath: join(staging, "provenance.json"),
|
||||||
snapshotPath: source.snapshot_path,
|
snapshots,
|
||||||
sourceBytes: snapshot,
|
|
||||||
files,
|
files,
|
||||||
});
|
});
|
||||||
return { version, admitted, run, parsed };
|
return { version, admitted, run, parsed };
|
||||||
@@ -109,7 +112,8 @@ const executeWakaruRecovery = async (context: RecoveryWorkspace) => {
|
|||||||
|
|
||||||
/** Publish validated recovery and retain exact source and engine bindings. */
|
/** Publish validated recovery and retain exact source and engine bindings. */
|
||||||
export const prepareWakaruExecution = async (context: RecoveryWorkspace) => {
|
export const prepareWakaruExecution = async (context: RecoveryWorkspace) => {
|
||||||
const { root, tree, input, source, engine, options } = context;
|
const { root, tree, input, sources, engine, options } = context;
|
||||||
|
const [source] = sources;
|
||||||
const staging = join(root, "modules");
|
const staging = join(root, "modules");
|
||||||
const { version, admitted, run, parsed } =
|
const { version, admitted, run, parsed } =
|
||||||
await executeWakaruRecovery(context);
|
await executeWakaruRecovery(context);
|
||||||
@@ -117,26 +121,25 @@ export const prepareWakaruExecution = async (context: RecoveryWorkspace) => {
|
|||||||
tree,
|
tree,
|
||||||
staging,
|
staging,
|
||||||
input,
|
input,
|
||||||
source,
|
sources,
|
||||||
engine,
|
engine,
|
||||||
admitted,
|
admitted,
|
||||||
parsed,
|
parsed,
|
||||||
execution: run,
|
execution: run,
|
||||||
...(options?.signal === undefined ? {} : { signal: options.signal }),
|
...(options?.signal === undefined ? {} : { signal: options.signal }),
|
||||||
});
|
});
|
||||||
if (
|
for (const selected of sources) {
|
||||||
recoveryDigest(
|
const current = await fingerprintRecoveryFile(
|
||||||
await readRecoveryFile(
|
selected.path,
|
||||||
input.path,
|
RECOVERY_LIMITS.inputBytes,
|
||||||
RECOVERY_LIMITS.inputBytes,
|
options?.signal,
|
||||||
options?.signal,
|
|
||||||
),
|
|
||||||
) !== source.sha256
|
|
||||||
)
|
|
||||||
throw new AnalysisOutputError(
|
|
||||||
OPERATION,
|
|
||||||
`Original input changed during recovery: ${input.path}`,
|
|
||||||
);
|
);
|
||||||
|
if (current.sha256 !== selected.sha256 || current.bytes !== selected.bytes)
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
OPERATION,
|
||||||
|
`Original input changed during recovery: ${selected.path}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
(
|
(
|
||||||
await fingerprintRecoveryFile(
|
await fingerprintRecoveryFile(
|
||||||
@@ -166,7 +169,10 @@ export const prepareWakaruExecution = async (context: RecoveryWorkspace) => {
|
|||||||
format: "javascript",
|
format: "javascript",
|
||||||
},
|
},
|
||||||
locations: [
|
locations: [
|
||||||
{ kind: "artifact-path", path: source.path },
|
...sources.map((selected) => ({
|
||||||
|
kind: "artifact-path" as const,
|
||||||
|
path: selected.path,
|
||||||
|
})),
|
||||||
...result.modules.map((module) => ({
|
...result.modules.map((module) => ({
|
||||||
kind: "artifact-path" as const,
|
kind: "artifact-path" as const,
|
||||||
path: module.artifact.path,
|
path: module.artifact.path,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
checkRecoveryDeadline,
|
checkRecoveryDeadline,
|
||||||
recoveryFailureMessage,
|
recoveryFailureMessage,
|
||||||
recoveryInputFailure,
|
recoveryInputFailure,
|
||||||
snapshotRecoveryInput,
|
snapshotRecoveryInputs,
|
||||||
} from "./RecoveryFiles.js";
|
} from "./RecoveryFiles.js";
|
||||||
import {
|
import {
|
||||||
resolveWakaruCommand,
|
resolveWakaruCommand,
|
||||||
@@ -55,7 +55,7 @@ export class WakaruProvider implements JavaScriptRecoveryPort {
|
|||||||
readonly launcher?: WakaruLauncher,
|
readonly launcher?: WakaruLauncher,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Recover one script using validated upstream reports and verified output bytes. */
|
/** Recover selected scripts using validated reports and verified output bytes. */
|
||||||
recover(
|
recover(
|
||||||
input: JavaScriptRecoveryInput,
|
input: JavaScriptRecoveryInput,
|
||||||
options?: ExecutionOptions,
|
options?: ExecutionOptions,
|
||||||
@@ -109,9 +109,9 @@ export class WakaruProvider implements JavaScriptRecoveryPort {
|
|||||||
const staging = join(root, "modules");
|
const staging = join(root, "modules");
|
||||||
await mkdir(inputs, { mode: 0o700 });
|
await mkdir(inputs, { mode: 0o700 });
|
||||||
await mkdir(staging, { mode: 0o700 });
|
await mkdir(staging, { mode: 0o700 });
|
||||||
const source = await snapshotRecoveryInput(
|
const sources = await snapshotRecoveryInputs(
|
||||||
input.path,
|
[input.path, ...(input.additional_paths ?? [])],
|
||||||
join(inputs, "bundle.js"),
|
inputs,
|
||||||
options?.signal,
|
options?.signal,
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
@@ -128,7 +128,7 @@ export class WakaruProvider implements JavaScriptRecoveryPort {
|
|||||||
root,
|
root,
|
||||||
tree,
|
tree,
|
||||||
input,
|
input,
|
||||||
source,
|
sources,
|
||||||
engine,
|
engine,
|
||||||
environment: this.environment,
|
environment: this.environment,
|
||||||
launcher: this.launcher,
|
launcher: this.launcher,
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ import {
|
|||||||
type JavaScriptRecoveryInput,
|
type JavaScriptRecoveryInput,
|
||||||
type JavaScriptRecoveryResult,
|
type JavaScriptRecoveryResult,
|
||||||
} from "../../domain/javascript/javascriptRecovery.js";
|
} from "../../domain/javascript/javascriptRecovery.js";
|
||||||
import { readRecoveryFile, recoveryDigest } from "./RecoveryFiles.js";
|
import {
|
||||||
|
readRecoveryFile,
|
||||||
|
recoveryDigest,
|
||||||
|
type RecoveryInputSnapshot,
|
||||||
|
} from "./RecoveryFiles.js";
|
||||||
import {
|
import {
|
||||||
recoveryRanges,
|
recoveryRanges,
|
||||||
validateRecoverySourceMap,
|
validateRecoverySourceMap,
|
||||||
@@ -28,12 +32,7 @@ type PublicationContext = {
|
|||||||
tree: SafeOutputTree;
|
tree: SafeOutputTree;
|
||||||
staging: string;
|
staging: string;
|
||||||
input: JavaScriptRecoveryInput;
|
input: JavaScriptRecoveryInput;
|
||||||
source: {
|
sources: readonly [RecoveryInputSnapshot, ...RecoveryInputSnapshot[]];
|
||||||
path: string;
|
|
||||||
snapshot_path: string;
|
|
||||||
sha256: string;
|
|
||||||
bytes: number;
|
|
||||||
};
|
|
||||||
engine: Awaited<ReturnType<typeof resolveWakaruCommand>>;
|
engine: Awaited<ReturnType<typeof resolveWakaruCommand>>;
|
||||||
admitted: Exclude<WakaruVersionAdmission, { status: "unsupported" }>;
|
admitted: Exclude<WakaruVersionAdmission, { status: "unsupported" }>;
|
||||||
parsed: Awaited<ReturnType<typeof parseWakaruReports>>;
|
parsed: Awaited<ReturnType<typeof parseWakaruReports>>;
|
||||||
@@ -47,7 +46,7 @@ type RecoveryWriter = (path: string, bytes: Buffer) => Promise<PublishedFile>;
|
|||||||
export const publishWakaruArtifacts = async (
|
export const publishWakaruArtifacts = async (
|
||||||
context: PublicationContext,
|
context: PublicationContext,
|
||||||
): Promise<JavaScriptRecoveryResult> => {
|
): Promise<JavaScriptRecoveryResult> => {
|
||||||
const { tree, source, parsed, staging, signal } = context;
|
const { tree, sources, parsed, staging, signal } = context;
|
||||||
const write = async (path: string, bytes: Buffer) => {
|
const write = async (path: string, bytes: Buffer) => {
|
||||||
const sha256 = recoveryDigest(bytes);
|
const sha256 = recoveryDigest(bytes);
|
||||||
const file = await tree.write(
|
const file = await tree.write(
|
||||||
@@ -62,17 +61,25 @@ export const publishWakaruArtifacts = async (
|
|||||||
bytes: file.bytesWritten,
|
bytes: file.bytesWritten,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const snapshot = await readRecoveryFile(
|
const publishedSources: JavaScriptRecoveryResult["source"][] = [];
|
||||||
source.snapshot_path,
|
for (const selected of sources) {
|
||||||
RECOVERY_LIMITS.inputBytes,
|
const { relative_path, ...source } = selected;
|
||||||
signal,
|
const snapshot = await readRecoveryFile(
|
||||||
);
|
source.snapshot_path,
|
||||||
if (recoveryDigest(snapshot) !== source.sha256)
|
source.bytes,
|
||||||
throw new AnalysisOutputError(
|
signal,
|
||||||
"recover_javascript_sources",
|
|
||||||
"The input snapshot changed during recovery",
|
|
||||||
);
|
);
|
||||||
const publishedCopy = await write("inputs/bundle.js", snapshot);
|
if (recoveryDigest(snapshot) !== source.sha256)
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
"recover_javascript_sources",
|
||||||
|
`The input snapshot changed during recovery: ${source.path}`,
|
||||||
|
);
|
||||||
|
publishedSources.push({
|
||||||
|
...source,
|
||||||
|
published_copy: await write(`inputs/${relative_path}`, snapshot),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const [source, ...additionalSources] = publishedSources;
|
||||||
const modules: JavaScriptRecoveryResult["modules"] = [];
|
const modules: JavaScriptRecoveryResult["modules"] = [];
|
||||||
for (const module of parsed.report.modules) {
|
for (const module of parsed.report.modules) {
|
||||||
modules.push(await publishModule(context, module, write));
|
modules.push(await publishModule(context, module, write));
|
||||||
@@ -90,7 +97,10 @@ export const publishWakaruArtifacts = async (
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
const details = {
|
const details = {
|
||||||
source: { ...source, published_copy: publishedCopy },
|
source,
|
||||||
|
...(additionalSources.length === 0
|
||||||
|
? {}
|
||||||
|
: { additional_sources: additionalSources }),
|
||||||
engine: {
|
engine: {
|
||||||
id: "wakaru",
|
id: "wakaru",
|
||||||
version: context.admitted.version,
|
version: context.admitted.version,
|
||||||
@@ -151,7 +161,7 @@ const publishModule = async (
|
|||||||
module: PublicationContext["parsed"]["report"]["modules"][number],
|
module: PublicationContext["parsed"]["report"]["modules"][number],
|
||||||
write: RecoveryWriter,
|
write: RecoveryWriter,
|
||||||
): Promise<JavaScriptRecoveryResult["modules"][number]> => {
|
): Promise<JavaScriptRecoveryResult["modules"][number]> => {
|
||||||
const { staging, source, parsed, signal } = context;
|
const { staging, sources, parsed, signal } = context;
|
||||||
const artifact = await write(
|
const artifact = await write(
|
||||||
`modules/${module.filename}`,
|
`modules/${module.filename}`,
|
||||||
await readRecoveryFile(
|
await readRecoveryFile(
|
||||||
@@ -182,6 +192,14 @@ const publishModule = async (
|
|||||||
"recover_javascript_sources",
|
"recover_javascript_sources",
|
||||||
`Missing module provenance: ${module.filename}`,
|
`Missing module provenance: ${module.filename}`,
|
||||||
);
|
);
|
||||||
|
const origin = sources.find(
|
||||||
|
(source) => source.snapshot_path === provenance.input,
|
||||||
|
);
|
||||||
|
if (origin === undefined)
|
||||||
|
throw new AnalysisOutputError(
|
||||||
|
"recover_javascript_sources",
|
||||||
|
`Unselected module provenance input: ${provenance.input}`,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
reported_filename: module.filename,
|
reported_filename: module.filename,
|
||||||
reported_status: module.status,
|
reported_status: module.status,
|
||||||
@@ -189,8 +207,8 @@ const publishModule = async (
|
|||||||
source_map: sourceMap,
|
source_map: sourceMap,
|
||||||
provenance: {
|
provenance: {
|
||||||
reported_input: provenance.input,
|
reported_input: provenance.input,
|
||||||
original_path: source.path,
|
original_path: origin.path,
|
||||||
original_sha256: source.sha256,
|
original_sha256: origin.sha256,
|
||||||
extraction: provenance.extraction,
|
extraction: provenance.extraction,
|
||||||
byte_ranges: recoveryRanges(provenance.ranges),
|
byte_ranges: recoveryRanges(provenance.ranges),
|
||||||
context_byte_ranges: recoveryRanges(provenance.context_ranges ?? []),
|
context_byte_ranges: recoveryRanges(provenance.context_ranges ?? []),
|
||||||
|
|||||||
@@ -36,5 +36,5 @@ export const RECOVERY_LIMITATIONS = [
|
|||||||
"Extraction ranges refer to half-open UTF-8 byte offsets in the original input, not rewritten line positions or whole-bundle coverage.",
|
"Extraction ranges refer to half-open UTF-8 byte offsets in the original input, not rewritten line positions or whole-bundle coverage.",
|
||||||
"Emitted source maps are preserved as reported by the engine; their mapping accuracy is not independently established.",
|
"Emitted source maps are preserved as reported by the engine; their mapping accuracy is not independently established.",
|
||||||
"Inspection mode may produce non-executable regions. No input or recovered application code is executed.",
|
"Inspection mode may produce non-executable regions. No input or recovered application code is executed.",
|
||||||
"Linux adapter: one worker, 1 GiB address-space ceiling, 120-second deadline, 64 MiB input, 128 MiB output, 10000 output entries and 8 MiB per diagnostic/report stream. Resource limits are per operation, not aggregate host containment.",
|
"Linux adapter: one worker, 1 GiB address-space ceiling, 120-second deadline, 64 MiB total input, 128 MiB output, 10000 output entries and 8 MiB per diagnostic/report stream. Resource limits are per operation, not aggregate host containment.",
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -54,8 +54,7 @@ const OPERATION = "recover_javascript_sources";
|
|||||||
type ReportContext = {
|
type ReportContext = {
|
||||||
execution: Awaited<ReturnType<typeof runWakaruCommand>>;
|
execution: Awaited<ReturnType<typeof runWakaruCommand>>;
|
||||||
provenancePath: string;
|
provenancePath: string;
|
||||||
snapshotPath: string;
|
snapshots: ReadonlyMap<string, Buffer>;
|
||||||
sourceBytes: Buffer;
|
|
||||||
files: readonly { relativePath: string }[];
|
files: readonly { relativePath: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -121,7 +120,7 @@ const validateModuleSet = (
|
|||||||
provenance: z.output<typeof provenanceSchema>,
|
provenance: z.output<typeof provenanceSchema>,
|
||||||
context: ReportContext,
|
context: ReportContext,
|
||||||
): void => {
|
): void => {
|
||||||
const { files, snapshotPath, sourceBytes } = context;
|
const { files, snapshots } = context;
|
||||||
const registry = new ArtifactPathRegistry();
|
const registry = new ArtifactPathRegistry();
|
||||||
const expected = new Set(["provenance.json"]);
|
const expected = new Set(["provenance.json"]);
|
||||||
const observed = new Set(files.map((file) => file.relativePath));
|
const observed = new Set(files.map((file) => file.relativePath));
|
||||||
@@ -135,7 +134,9 @@ const validateModuleSet = (
|
|||||||
expected.add(path);
|
expected.add(path);
|
||||||
if (observed.has(`${path}.map`)) expected.add(`${path}.map`);
|
if (observed.has(`${path}.map`)) expected.add(`${path}.map`);
|
||||||
const entry = provenance.modules[path];
|
const entry = provenance.modules[path];
|
||||||
if (entry === undefined || entry.input !== snapshotPath)
|
const sourceBytes =
|
||||||
|
entry === undefined ? undefined : snapshots.get(entry.input);
|
||||||
|
if (entry === undefined || sourceBytes === undefined)
|
||||||
throw new TypeError(
|
throw new TypeError(
|
||||||
`Module provenance does not bind the selected input snapshot: ${path}`,
|
`Module provenance does not bind the selected input snapshot: ${path}`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ interface Section {
|
|||||||
readonly size: number;
|
readonly size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The image loader maps VirtualSize bytes, using SizeOfRawData only when
|
||||||
|
// VirtualSize is zero. Raw bytes past that extent are file-alignment padding.
|
||||||
|
const virtualExtent = (section: Section): number =>
|
||||||
|
section.virtualSize === 0 ? section.size : section.virtualSize;
|
||||||
|
|
||||||
/** Header admission and unambiguous file-backed RVA mapping, independent of section names. */
|
/** Header admission and unambiguous file-backed RVA mapping, independent of section names. */
|
||||||
export const readPeResourceLayout = (bytes: Buffer) => {
|
export const readPeResourceLayout = (bytes: Buffer) => {
|
||||||
requirePeRange(bytes, 0, 64);
|
requirePeRange(bytes, 0, 64);
|
||||||
@@ -64,7 +69,7 @@ export const readPeResourceLayout = (bytes: Buffer) => {
|
|||||||
offset: bytes.readUInt32LE(at + 20),
|
offset: bytes.readUInt32LE(at + 20),
|
||||||
size: bytes.readUInt32LE(at + 16),
|
size: bytes.readUInt32LE(at + 16),
|
||||||
};
|
};
|
||||||
if (section.rva + Math.max(section.virtualSize, section.size) > 0x100000000)
|
if (section.rva + virtualExtent(section) > 0x100000000)
|
||||||
peFailure("PE section RVA range overflows.");
|
peFailure("PE section RVA range overflows.");
|
||||||
if (section.size > 0) {
|
if (section.size > 0) {
|
||||||
requirePeRange(bytes, section.offset, section.size);
|
requirePeRange(bytes, section.offset, section.size);
|
||||||
@@ -90,15 +95,14 @@ export const readPeResourceLayout = (bytes: Buffer) => {
|
|||||||
if (rva + size > 0x100000000) peFailure("Resource RVA range overflows.");
|
if (rva + size > 0x100000000) peFailure("Resource RVA range overflows.");
|
||||||
const candidates = sections.filter(
|
const candidates = sections.filter(
|
||||||
(section) =>
|
(section) =>
|
||||||
rva >= section.rva &&
|
rva >= section.rva && rva - section.rva < virtualExtent(section),
|
||||||
rva - section.rva < Math.max(section.size, section.virtualSize),
|
|
||||||
);
|
);
|
||||||
if (rva < headerSize) {
|
if (rva < headerSize) {
|
||||||
if (
|
if (
|
||||||
sections.some(
|
sections.some(
|
||||||
(section) =>
|
(section) =>
|
||||||
section.rva < rva + size &&
|
section.rva < rva + size &&
|
||||||
section.rva + Math.max(section.size, section.virtualSize) > rva,
|
section.rva + virtualExtent(section) > rva,
|
||||||
) ||
|
) ||
|
||||||
size > headerSize - rva
|
size > headerSize - rva
|
||||||
)
|
)
|
||||||
@@ -111,6 +115,8 @@ export const readPeResourceLayout = (bytes: Buffer) => {
|
|||||||
const section = candidates[0];
|
const section = candidates[0];
|
||||||
if (section === undefined) return peFailure("Missing resource section.");
|
if (section === undefined) return peFailure("Missing resource section.");
|
||||||
const within = rva - section.rva;
|
const within = rva - section.rva;
|
||||||
|
if (size > virtualExtent(section) - within)
|
||||||
|
peFailure("Resource range leaves its section's virtual size.");
|
||||||
if (within > section.size || size > section.size - within)
|
if (within > section.size || size > section.size - within)
|
||||||
peFailure("Resource range is not entirely file-backed in one section.");
|
peFailure("Resource range is not entirely file-backed in one section.");
|
||||||
// A second section starting inside the requested interval is ambiguous too.
|
// A second section starting inside the requested interval is ambiguous too.
|
||||||
@@ -119,7 +125,7 @@ export const readPeResourceLayout = (bytes: Buffer) => {
|
|||||||
(other) =>
|
(other) =>
|
||||||
other !== section &&
|
other !== section &&
|
||||||
other.rva < rva + size &&
|
other.rva < rva + size &&
|
||||||
other.rva + Math.max(other.size, other.virtualSize) > rva,
|
other.rva + virtualExtent(other) > rva,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
peFailure("Resource interval intersects overlapping virtual sections.");
|
peFailure("Resource interval intersects overlapping virtual sections.");
|
||||||
|
|||||||
@@ -259,3 +259,62 @@ describe("PE header admission and bounded traversal", () => {
|
|||||||
await expect(parsing).rejects.toThrow();
|
await expect(parsing).rejects.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("PE resource section virtual extent", () => {
|
||||||
|
const sectionSizes = (fixture: ReturnType<typeof peResourceFixture>) => {
|
||||||
|
const virtualSize = fixture.bytes.readUInt32LE(fixture.sectionAt + 8);
|
||||||
|
const rawSize = fixture.bytes.readUInt32LE(fixture.sectionAt + 16);
|
||||||
|
expect(rawSize - virtualSize).toBeGreaterThanOrEqual(8);
|
||||||
|
return { virtualSize, rawSize };
|
||||||
|
};
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[0, "Resource RVA has missing or overlapping section mappings."],
|
||||||
|
[-2, "Resource range leaves its section's virtual size."],
|
||||||
|
])(
|
||||||
|
"rejects a payload reaching raw alignment padding past VirtualSize (start %i)",
|
||||||
|
async (start, message) => {
|
||||||
|
const fixture = peResourceFixture();
|
||||||
|
const { virtualSize } = sectionSizes(fixture);
|
||||||
|
const rva = 0x2000 + virtualSize + start;
|
||||||
|
fixture.bytes.writeUInt32LE(rva, fixture.dataOffsets[0]);
|
||||||
|
fixture.bytes.writeUInt32LE(4, (fixture.dataOffsets[0] ?? 0) + 4);
|
||||||
|
await expect(parse(fixture.bytes)).rejects.toMatchObject({
|
||||||
|
reason: "format",
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("maps raw bytes when VirtualSize is zero, as the image loader does", async () => {
|
||||||
|
const fixture = peResourceFixture();
|
||||||
|
fixture.bytes.writeUInt32LE(0, fixture.sectionAt + 8);
|
||||||
|
expect(await parse(fixture.bytes)).toMatchObject({
|
||||||
|
coverage: { status: "complete", resources: 5 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects a later section that starts inside earlier raw padding", async () => {
|
||||||
|
const fixture = peResourceFixture();
|
||||||
|
const { virtualSize } = sectionSizes(fixture);
|
||||||
|
const laterRva = 0x2000 + Math.ceil(virtualSize / 4) * 4;
|
||||||
|
const rawOffset = fixture.bytes.length;
|
||||||
|
const payload = Buffer.from("LATE");
|
||||||
|
const bytes = Buffer.concat([fixture.bytes, payload]);
|
||||||
|
bytes.writeUInt16LE(2, 0x86);
|
||||||
|
const later = fixture.sectionAt + 40;
|
||||||
|
bytes.writeUInt32LE(payload.length, later + 8);
|
||||||
|
bytes.writeUInt32LE(laterRva, later + 12);
|
||||||
|
bytes.writeUInt32LE(payload.length, later + 16);
|
||||||
|
bytes.writeUInt32LE(rawOffset, later + 20);
|
||||||
|
bytes.writeUInt32LE(laterRva, fixture.dataOffsets[0]);
|
||||||
|
bytes.writeUInt32LE(payload.length, (fixture.dataOffsets[0] ?? 0) + 4);
|
||||||
|
|
||||||
|
const result = peResourcesSchema.parse(await parse(bytes));
|
||||||
|
expect(result.resources[0]?.payload).toMatchObject({
|
||||||
|
rva: laterRva,
|
||||||
|
location: { offset: rawOffset, bytes: payload.length },
|
||||||
|
sha256: createHash("sha256").update(payload).digest("hex"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import type { ExecutionOptions } from "../../application/AnalysisProvider.js";
|
|||||||
import { ArtifactOperationError } from "../../domain/artifactOperationError.js";
|
import { ArtifactOperationError } from "../../domain/artifactOperationError.js";
|
||||||
import {
|
import {
|
||||||
AnalysisAccessDeniedError,
|
AnalysisAccessDeniedError,
|
||||||
|
AnalysisArtifactChangedError,
|
||||||
AnalysisCancelledError,
|
AnalysisCancelledError,
|
||||||
|
AnalysisInputError,
|
||||||
AnalysisResourceConstraintError,
|
AnalysisResourceConstraintError,
|
||||||
} from "../../domain/analysisErrorCore.js";
|
} from "../../domain/analysisErrorCore.js";
|
||||||
import type { InspectPeResourcesInput } from "../../domain/native/peResources.js";
|
import type { InspectPeResourcesInput } from "../../domain/native/peResources.js";
|
||||||
@@ -37,47 +39,70 @@ export class PeResourcesProvider implements PeResourcesPort {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (options?.signal?.aborted)
|
return err(peResourcesFailure(cause, input, options?.signal));
|
||||||
return err(
|
|
||||||
new AnalysisCancelledError("inspect_pe_resources", { cause }),
|
|
||||||
);
|
|
||||||
if (cause instanceof ArtifactReaderFailure && cause.reason === "limit")
|
|
||||||
return err(
|
|
||||||
new AnalysisResourceConstraintError(
|
|
||||||
"inspect_pe_resources",
|
|
||||||
"memory",
|
|
||||||
cause.message,
|
|
||||||
{
|
|
||||||
max_file_bytes: input.max_file_bytes,
|
|
||||||
max_entries: input.max_entries,
|
|
||||||
},
|
|
||||||
{ cause },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
cause instanceof Error &&
|
|
||||||
"code" in cause &&
|
|
||||||
(cause.code === "EACCES" || cause.code === "EPERM")
|
|
||||||
)
|
|
||||||
return err(
|
|
||||||
new AnalysisAccessDeniedError(
|
|
||||||
"inspect_pe_resources",
|
|
||||||
input.path,
|
|
||||||
cause.code,
|
|
||||||
{ cause },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return err(
|
|
||||||
new ArtifactOperationError(
|
|
||||||
"inspect_pe_resources",
|
|
||||||
cause instanceof ArtifactReaderFailure ? cause.reason : "io",
|
|
||||||
undefined,
|
|
||||||
cause instanceof Error
|
|
||||||
? cause.message
|
|
||||||
: "PE artifact inspection failed.",
|
|
||||||
{ cause },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const peResourcesFailure = (
|
||||||
|
cause: unknown,
|
||||||
|
input: InspectPeResourcesInput,
|
||||||
|
signal: AbortSignal | undefined,
|
||||||
|
) => {
|
||||||
|
const operation = "inspect_pe_resources";
|
||||||
|
if (signal?.aborted) return new AnalysisCancelledError(operation, { cause });
|
||||||
|
|
||||||
|
if (cause instanceof ArtifactReaderFailure) {
|
||||||
|
if (cause.reason === "limit")
|
||||||
|
return new AnalysisResourceConstraintError(
|
||||||
|
operation,
|
||||||
|
"memory",
|
||||||
|
cause.message,
|
||||||
|
{
|
||||||
|
max_file_bytes: input.max_file_bytes,
|
||||||
|
max_entries: input.max_entries,
|
||||||
|
},
|
||||||
|
{ cause },
|
||||||
|
);
|
||||||
|
if (cause.reason === "integrity")
|
||||||
|
return new AnalysisArtifactChangedError(
|
||||||
|
operation,
|
||||||
|
input.path,
|
||||||
|
cause.message,
|
||||||
|
{ cause },
|
||||||
|
);
|
||||||
|
if (cause.reason === "path" || cause.reason === "format")
|
||||||
|
return new AnalysisInputError(operation, { cause }, [
|
||||||
|
{
|
||||||
|
path: ["path"],
|
||||||
|
reason: "invalid_format",
|
||||||
|
message: cause.message,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
if (cause.reason === "cancelled")
|
||||||
|
return new AnalysisCancelledError(operation, { cause });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cause instanceof Error && "code" in cause) {
|
||||||
|
if (cause.code === "EACCES" || cause.code === "EPERM")
|
||||||
|
return new AnalysisAccessDeniedError(operation, input.path, cause.code, {
|
||||||
|
cause,
|
||||||
|
});
|
||||||
|
if (cause.code === "ENOENT" || cause.code === "ENOTDIR")
|
||||||
|
return new AnalysisInputError(operation, { cause }, [
|
||||||
|
{
|
||||||
|
path: ["path"],
|
||||||
|
reason: "invalid_value",
|
||||||
|
message: `Selected PE image could not be read (${cause.code}): ${input.path}.`,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ArtifactOperationError(
|
||||||
|
operation,
|
||||||
|
cause instanceof ArtifactReaderFailure ? cause.reason : "io",
|
||||||
|
undefined,
|
||||||
|
cause instanceof Error ? cause.message : "PE artifact inspection failed.",
|
||||||
|
{ cause },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,27 +1,53 @@
|
|||||||
import type { FileHandle } from "node:fs/promises";
|
import type { FileHandle } from "node:fs/promises";
|
||||||
|
|
||||||
/** Read through EOF, returning undefined on overflow; consume at most maxBytes + 1 bytes without closing the handle. */
|
/** Read through EOF, returning undefined on overflow; leave the handle open. */
|
||||||
export const readBoundedFileBytes = async (
|
export const readBoundedFileBytes = async (
|
||||||
handle: FileHandle,
|
handle: FileHandle,
|
||||||
maxBytes: number,
|
maxBytes: number,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
|
expectedSize?: number,
|
||||||
): Promise<Buffer | undefined> => {
|
): Promise<Buffer | undefined> => {
|
||||||
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0)
|
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0)
|
||||||
throw new RangeError("File byte limit must be a nonnegative safe integer");
|
throw new RangeError("File byte limit must be a nonnegative safe integer");
|
||||||
const chunks: Buffer[] = [];
|
if (
|
||||||
const buffer = Buffer.allocUnsafe(Math.min(64 * 1_024, maxBytes + 1));
|
expectedSize !== undefined &&
|
||||||
|
(!Number.isSafeInteger(expectedSize) ||
|
||||||
|
expectedSize < 0 ||
|
||||||
|
expectedSize > maxBytes)
|
||||||
|
)
|
||||||
|
throw new RangeError("Expected file size must fit the byte limit");
|
||||||
|
if (expectedSize === undefined) {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
const buffer = Buffer.allocUnsafe(Math.min(64 * 1_024, maxBytes + 1));
|
||||||
|
let total = 0;
|
||||||
|
for (;;) {
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
const { bytesRead } = await handle.read(
|
||||||
|
buffer,
|
||||||
|
0,
|
||||||
|
Math.min(buffer.length, maxBytes - total + 1),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
if (bytesRead === 0) return Buffer.concat(chunks, total);
|
||||||
|
total += bytesRead;
|
||||||
|
if (total > maxBytes) return undefined;
|
||||||
|
chunks.push(Buffer.from(buffer.subarray(0, bytesRead)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const buffer = Buffer.allocUnsafe(expectedSize);
|
||||||
|
if (expectedSize === 0) return buffer;
|
||||||
let total = 0;
|
let total = 0;
|
||||||
for (;;) {
|
for (;;) {
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
const { bytesRead } = await handle.read(
|
const { bytesRead } = await handle.read(
|
||||||
buffer,
|
buffer,
|
||||||
0,
|
total,
|
||||||
Math.min(buffer.length, maxBytes - total + 1),
|
expectedSize - total,
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
if (bytesRead === 0) return Buffer.concat(chunks, total);
|
if (bytesRead === 0) return buffer.subarray(0, total);
|
||||||
total += bytesRead;
|
total += bytesRead;
|
||||||
if (total > maxBytes) return undefined;
|
if (total > maxBytes) return undefined;
|
||||||
chunks.push(Buffer.from(buffer.subarray(0, bytesRead)));
|
if (total === expectedSize) return buffer;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import { expect } from "vitest";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
|
||||||
|
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
|
||||||
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||||
|
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||||
|
|
||||||
|
cliTest.for(["CLI", "stdio MCP"] as const)(
|
||||||
|
"keeps dependency manifests out of application roots and Electron entries through %s",
|
||||||
|
async (surface, { cli }) => {
|
||||||
|
const root = await createTestTempDirectory("rea-application-manifest-");
|
||||||
|
const dependency = join(root, "node_modules", "dep");
|
||||||
|
const nested = join(root, "a");
|
||||||
|
await Promise.all([mkdir(dependency, { recursive: true }), mkdir(nested)]);
|
||||||
|
await Promise.all([
|
||||||
|
writeFile(
|
||||||
|
join(nested, "package.json"),
|
||||||
|
JSON.stringify({ name: "nested" }),
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
join(root, "package.json"),
|
||||||
|
JSON.stringify({ name: "app", main: "main.js" }),
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
join(root, "main.js"),
|
||||||
|
"throw new Error('source must remain inert');",
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
join(dependency, "package.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
name: "dep",
|
||||||
|
main: "index.js",
|
||||||
|
browser: "browser.js",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
writeFile(join(dependency, "index.js"), "module.exports = {};"),
|
||||||
|
writeFile(join(dependency, "browser.js"), "export const browser = true;"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let document: unknown;
|
||||||
|
if (surface === "CLI") {
|
||||||
|
const response = await cli.run({
|
||||||
|
arguments: ["analyze-javascript-application", root, "--json"],
|
||||||
|
});
|
||||||
|
expect(response.exitCode).toBe(0);
|
||||||
|
document = response.json;
|
||||||
|
} else {
|
||||||
|
const { call } = await connectLocalToolsMcp();
|
||||||
|
const response = await call("analyze_javascript_application", {
|
||||||
|
input_path: root,
|
||||||
|
});
|
||||||
|
expect(response.isError, JSON.stringify(response)).not.toBe(true);
|
||||||
|
document = response.structuredContent;
|
||||||
|
}
|
||||||
|
const { graph } = z
|
||||||
|
.object({ normalized_result: javascriptApplicationAnalysisResultSchema })
|
||||||
|
.parse(document).normalized_result;
|
||||||
|
expect(
|
||||||
|
graph.nodes
|
||||||
|
.filter(({ node_id }) => graph.root_node_ids.includes(node_id))
|
||||||
|
.flatMap(({ observations }) => observations.map(({ label }) => label)),
|
||||||
|
).toEqual(["app"]);
|
||||||
|
expect(
|
||||||
|
graph.nodes
|
||||||
|
.filter(({ kind }) => kind === "electron-main")
|
||||||
|
.flatMap(({ observations }) =>
|
||||||
|
observations.map(({ properties }) => properties.declared_path),
|
||||||
|
),
|
||||||
|
).toEqual(["main.js"]);
|
||||||
|
expect(
|
||||||
|
graph.nodes.filter(({ kind }) => kind === "electron-renderer"),
|
||||||
|
).toEqual([]);
|
||||||
|
expect(
|
||||||
|
graph.nodes
|
||||||
|
.filter(({ kind }) => kind === "package")
|
||||||
|
.flatMap(({ observations }) => observations.map(({ label }) => label))
|
||||||
|
.sort(),
|
||||||
|
).toEqual(["app", "dep", "nested"]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
cliTest(
|
||||||
|
"keeps the artifact root when only dependency manifests are present",
|
||||||
|
async ({ cli }) => {
|
||||||
|
const root = await createTestTempDirectory("rea-dependency-only-");
|
||||||
|
const dependency = join(root, "node_modules", "dep");
|
||||||
|
await mkdir(dependency, { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
join(dependency, "package.json"),
|
||||||
|
JSON.stringify({ name: "dep", main: "index.js" }),
|
||||||
|
);
|
||||||
|
await writeFile(join(dependency, "index.js"), "module.exports = {};");
|
||||||
|
const response = await cli.run({
|
||||||
|
arguments: ["analyze-javascript-application", root, "--json"],
|
||||||
|
});
|
||||||
|
expect(response.exitCode, response.stderr).toBe(0);
|
||||||
|
const { graph } = z
|
||||||
|
.object({ normalized_result: javascriptApplicationAnalysisResultSchema })
|
||||||
|
.parse(response.json).normalized_result;
|
||||||
|
expect(
|
||||||
|
graph.nodes
|
||||||
|
.filter(({ node_id }) => graph.root_node_ids.includes(node_id))
|
||||||
|
.map(({ kind }) => kind),
|
||||||
|
).toEqual(["artifact"]);
|
||||||
|
expect(
|
||||||
|
graph.nodes
|
||||||
|
.filter(({ kind }) => kind === "package")
|
||||||
|
.flatMap(({ observations }) => observations.map(({ label }) => label)),
|
||||||
|
).toEqual(["dep"]);
|
||||||
|
expect(
|
||||||
|
graph.nodes.some(
|
||||||
|
({ kind }) => kind === "electron-main" || kind === "electron-renderer",
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -8,7 +8,36 @@ import { toolContract } from "../../../src/contracts/toolContracts.js";
|
|||||||
import { peResourcesSchema } from "../../../src/domain/native/peResources.js";
|
import { peResourcesSchema } from "../../../src/domain/native/peResources.js";
|
||||||
import { peResourceFixture } from "../../../src/native/pe/PeResources.fixture.js";
|
import { peResourceFixture } from "../../../src/native/pe/PeResources.fixture.js";
|
||||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||||
|
import { cliTest, type TestCli } from "../../support/cli/cliFixture.js";
|
||||||
|
|
||||||
|
const expectPeInputFailureAcrossAdapters = async (input: {
|
||||||
|
readonly cli: TestCli;
|
||||||
|
readonly client: Client;
|
||||||
|
readonly toolName: string;
|
||||||
|
readonly path: string;
|
||||||
|
readonly reason: "invalid_format" | "invalid_value";
|
||||||
|
}) => {
|
||||||
|
const { cli, client, toolName, path, reason } = input;
|
||||||
|
const result = await cli.run({
|
||||||
|
arguments: ["inspect-pe-resources", path, "--json"],
|
||||||
|
});
|
||||||
|
expect(result.exitCode).toBe(1);
|
||||||
|
expect(result.json).toMatchObject({
|
||||||
|
code: "invalid_request",
|
||||||
|
category: "invalid_input",
|
||||||
|
details: {
|
||||||
|
operation: "inspect_pe_resources",
|
||||||
|
issues: [{ path: ["path"], reason }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const response = await client.callTool({
|
||||||
|
name: toolName,
|
||||||
|
arguments: { path },
|
||||||
|
});
|
||||||
|
expect(response.isError).toBe(true);
|
||||||
|
expect(parseMcpToolError(response)).toEqual({ error: result.json });
|
||||||
|
};
|
||||||
|
|
||||||
cliTest(
|
cliTest(
|
||||||
"inspects PE32/PE32+ resources through compiled CLI and real stdio MCP without an active target",
|
"inspects PE32/PE32+ resources through compiled CLI and real stdio MCP without an active target",
|
||||||
@@ -92,6 +121,20 @@ cliTest(
|
|||||||
).toBe(evidence.evidence_id);
|
).toBe(evidence.evidence_id);
|
||||||
expect(await readFile(path)).toEqual(fixture.bytes);
|
expect(await readFile(path)).toEqual(fixture.bytes);
|
||||||
}
|
}
|
||||||
|
const malformedPath = join(root, "malformed.exe");
|
||||||
|
await writeFile(malformedPath, Buffer.from("not a PE image"));
|
||||||
|
for (const [path, reason] of [
|
||||||
|
[join(root, "missing.exe"), "invalid_value"],
|
||||||
|
[malformedPath, "invalid_format"],
|
||||||
|
] as const) {
|
||||||
|
await expectPeInputFailureAcrossAdapters({
|
||||||
|
cli,
|
||||||
|
client,
|
||||||
|
toolName: contract.name,
|
||||||
|
path,
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
}
|
||||||
const absent = peResourceFixture();
|
const absent = peResourceFixture();
|
||||||
absent.bytes.fill(0, absent.directoryAt, absent.directoryAt + 8);
|
absent.bytes.fill(0, absent.directoryAt, absent.directoryAt + 8);
|
||||||
const path = join(root, "absent.exe");
|
const path = join(root, "absent.exe");
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js";
|
|||||||
import {
|
import {
|
||||||
access,
|
access,
|
||||||
chmod,
|
chmod,
|
||||||
|
mkdir,
|
||||||
mkdtemp,
|
mkdtemp,
|
||||||
rm,
|
rm,
|
||||||
|
symlink,
|
||||||
truncate,
|
truncate,
|
||||||
writeFile,
|
writeFile,
|
||||||
} from "node:fs/promises";
|
} from "node:fs/promises";
|
||||||
@@ -11,6 +13,7 @@ import { tmpdir } from "node:os";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { expect, it, onTestFinished } from "vitest";
|
import { expect, it, onTestFinished } from "vitest";
|
||||||
import { HistoricalCaptureDecoder } from "../../../src/browser/history/HistoricalCaptureDecoder.js";
|
import { HistoricalCaptureDecoder } from "../../../src/browser/history/HistoricalCaptureDecoder.js";
|
||||||
|
import { MitmproxyCaptureAdapter } from "../../../src/browser/history/MitmproxyCaptureAdapter.js";
|
||||||
import { HAR_CAPTURE_PROVIDER_IDENTITY } from "../../../src/browser/history/CaptureRelease.js";
|
import { HAR_CAPTURE_PROVIDER_IDENTITY } from "../../../src/browser/history/CaptureRelease.js";
|
||||||
import {
|
import {
|
||||||
inspectWebNetworkCaptureInputSchema,
|
inspectWebNetworkCaptureInputSchema,
|
||||||
@@ -20,6 +23,41 @@ import { historicalHar } from "../../fixtures/historicalHar.js";
|
|||||||
import { decodeHarCapture } from "../../../src/browser/history/HarCapture.js";
|
import { decodeHarCapture } from "../../../src/browser/history/HarCapture.js";
|
||||||
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
||||||
|
|
||||||
|
const linux = it.skipIf(process.platform !== "linux");
|
||||||
|
|
||||||
|
linux("refuses a directory configured as the mitmdump executable", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "rea-mitmdump-command-"));
|
||||||
|
onTestFinished(() => rm(root, { recursive: true, force: true }));
|
||||||
|
const command = join(root, "mitmdump");
|
||||||
|
await mkdir(command);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
new MitmproxyCaptureAdapter({ REA_MITMDUMP_COMMAND: command }).command(
|
||||||
|
join(root, "request.json"),
|
||||||
|
join(root, "runtime"),
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
_tag: "AnalysisCapabilityUnavailableError",
|
||||||
|
reason: expect.stringContaining(command),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
linux("allows a symlink to a regular mitmdump executable", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "rea-mitmdump-command-symlink-"));
|
||||||
|
onTestFinished(() => rm(root, { recursive: true, force: true }));
|
||||||
|
const command = join(root, "mitmdump");
|
||||||
|
const target = join(root, "mitmdump-bin");
|
||||||
|
await writeFile(target, "#!/bin/sh\n", { mode: 0o700 });
|
||||||
|
await symlink(target, command);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
new MitmproxyCaptureAdapter({ REA_MITMDUMP_COMMAND: command }).command(
|
||||||
|
join(root, "request.json"),
|
||||||
|
join(root, "runtime"),
|
||||||
|
),
|
||||||
|
).resolves.toMatchObject({ command });
|
||||||
|
});
|
||||||
|
|
||||||
it.each([true, false])(
|
it.each([true, false])(
|
||||||
"binds decoder replies to the complete selected provider identity (matching name: %s)",
|
"binds decoder replies to the complete selected provider identity (matching name: %s)",
|
||||||
async (matching) => {
|
async (matching) => {
|
||||||
|
|||||||
@@ -368,6 +368,30 @@ it.each([
|
|||||||
{ code: true, docs: true, evm: true, package: false },
|
{ code: true, docs: true, evm: true, package: false },
|
||||||
],
|
],
|
||||||
["scripts/unknown-website-helper.mjs", { code: true, package: true }],
|
["scripts/unknown-website-helper.mjs", { code: true, package: true }],
|
||||||
|
[
|
||||||
|
".github/workflows/release.yml",
|
||||||
|
{ workflows: true, code: true, package: false, website: false },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
".github/workflows/real-ghidra-windows.yml",
|
||||||
|
{ workflows: true, code: true, package: false, website: false },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"tests/conformance/c/fixture.c",
|
||||||
|
{ code: true, fixtures: true, readiness: true, package: false },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"tests/conformance/swift/fixture.swift",
|
||||||
|
{ code: true, fixtures: true, readiness: true, package: false },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"tests/conformance/readiness/javascript-cli/package.json",
|
||||||
|
{ code: true, fixtures: false, readiness: true, package: false },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"tests/conformance/ghidra/no-return.c",
|
||||||
|
{ code: true, fixtures: false, readiness: false, package: false },
|
||||||
|
],
|
||||||
])("preserves scope boundaries for %s", async (path, expected) => {
|
])("preserves scope boundaries for %s", async (path, expected) => {
|
||||||
const { directory, base } = await fixture();
|
const { directory, base } = await fixture();
|
||||||
const head = await change(directory, [path]);
|
const head = await change(directory, [path]);
|
||||||
|
|||||||
@@ -6,17 +6,35 @@ import { peResourceFixture } from "../../../src/native/pe/PeResources.fixture.js
|
|||||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||||
|
|
||||||
describe("PE resource stable filesystem boundary", () => {
|
describe("PE resource stable filesystem boundary", () => {
|
||||||
it("rejects symlinks, missing files and directories without inventing empty inventory", async () => {
|
it("reports non-file and malformed PE selections as caller input errors", async () => {
|
||||||
const root = await createTestTempDirectory("rea-pe-resource-files-");
|
const root = await createTestTempDirectory("rea-pe-resource-files-");
|
||||||
const path = join(root, "image.exe");
|
const path = join(root, "image.exe");
|
||||||
const alias = join(root, "alias.exe");
|
const alias = join(root, "alias.exe");
|
||||||
|
const malformed = join(root, "malformed.exe");
|
||||||
await writeFile(path, peResourceFixture().bytes);
|
await writeFile(path, peResourceFixture().bytes);
|
||||||
|
await writeFile(malformed, Buffer.from("not a PE image"));
|
||||||
await symlink(path, alias);
|
await symlink(path, alias);
|
||||||
const service = createPeResourcesService();
|
const service = createPeResourcesService();
|
||||||
for (const candidate of [root, alias, join(root, "missing.exe")]) {
|
for (const [candidate, reason] of [
|
||||||
|
[root, "invalid_format"],
|
||||||
|
[alias, "invalid_format"],
|
||||||
|
[malformed, "invalid_format"],
|
||||||
|
[join(root, "missing.exe"), "invalid_value"],
|
||||||
|
[join(path, "child.exe"), "invalid_value"],
|
||||||
|
] as const) {
|
||||||
const result = await service.inspect({ path: candidate });
|
const result = await service.inspect({ path: candidate });
|
||||||
if (result.ok) throw new Error("Expected file acquisition failure");
|
if (result.ok) throw new Error("Expected file acquisition failure");
|
||||||
expect(result.error._tag).toBe("ArtifactOperationError");
|
expect(result.error).toMatchObject({
|
||||||
|
_tag: "AnalysisInputError",
|
||||||
|
operation: "inspect_pe_resources",
|
||||||
|
issues: [
|
||||||
|
{
|
||||||
|
path: ["path"],
|
||||||
|
reason,
|
||||||
|
message: expect.any(String),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { mkdir, symlink, writeFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { expect, it } from "vitest";
|
||||||
|
import { resolveWakaruCommand } from "../../../src/javascript/recovery/WakaruCommand.js";
|
||||||
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||||
|
|
||||||
|
const linuxX64 = it.skipIf(
|
||||||
|
process.platform !== "linux" || process.arch !== "x64",
|
||||||
|
);
|
||||||
|
|
||||||
|
linuxX64(
|
||||||
|
"refuses a directory configured as the Wakaru resource limiter",
|
||||||
|
async () => {
|
||||||
|
const root = await createTestTempDirectory("rea-wakaru-limiter-");
|
||||||
|
const command = join(root, "wakaru");
|
||||||
|
const limiter = join(root, "prlimit");
|
||||||
|
await writeFile(command, "#!/bin/sh\n", { mode: 0o700 });
|
||||||
|
await mkdir(limiter);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
resolveWakaruCommand({
|
||||||
|
REA_WAKARU_COMMAND: command,
|
||||||
|
REA_JAVASCRIPT_PRLIMIT_COMMAND: limiter,
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
_tag: "AnalysisCapabilityUnavailableError",
|
||||||
|
reason: expect.stringContaining(limiter),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
linuxX64("allows a symlink to a regular Wakaru resource limiter", async () => {
|
||||||
|
const root = await createTestTempDirectory("rea-wakaru-limiter-symlink-");
|
||||||
|
const command = join(root, "wakaru");
|
||||||
|
const limiter = join(root, "prlimit");
|
||||||
|
const target = join(root, "prlimit-bin");
|
||||||
|
await writeFile(command, "#!/bin/sh\n", { mode: 0o700 });
|
||||||
|
await writeFile(target, "#!/bin/sh\n", { mode: 0o700 });
|
||||||
|
await symlink(target, limiter);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
resolveWakaruCommand({
|
||||||
|
REA_WAKARU_COMMAND: command,
|
||||||
|
REA_JAVASCRIPT_PRLIMIT_COMMAND: limiter,
|
||||||
|
}),
|
||||||
|
).resolves.toMatchObject({ limiter });
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
import { access, mkdir, open, readFile, writeFile } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { SafeOutputTree } from "../../../src/artifacts/SafeOutputTree.js";
|
import { SafeOutputTree } from "../../../src/artifacts/SafeOutputTree.js";
|
||||||
@@ -6,6 +6,7 @@ import { SafeOutputTreeCreationFailure } from "../../../src/artifacts/SafeOutput
|
|||||||
import { javascriptRecoveryResultSchema } from "../../../src/domain/javascript/javascriptRecovery.js";
|
import { javascriptRecoveryResultSchema } from "../../../src/domain/javascript/javascriptRecovery.js";
|
||||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||||
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
||||||
|
import { RECOVERY_LIMITS } from "../../../src/javascript/recovery/WakaruRelease.js";
|
||||||
import {
|
import {
|
||||||
assertRecoveryCleanup,
|
assertRecoveryCleanup,
|
||||||
recoveryFixture,
|
recoveryFixture,
|
||||||
@@ -36,6 +37,7 @@ describe.skipIf(process.platform !== "linux" || process.arch !== "x64")(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(value.source.path).toBe(fixture.path);
|
expect(value.source.path).toBe(fixture.path);
|
||||||
|
expect(value.additional_sources).toBeUndefined();
|
||||||
expect(value.source.published_copy.sha256).toBe(value.source.sha256);
|
expect(value.source.published_copy.sha256).toBe(value.source.sha256);
|
||||||
expect(value.modules[0]?.provenance.byte_ranges).toEqual([
|
expect(value.modules[0]?.provenance.byte_ranges).toEqual([
|
||||||
{ start: 0, end: value.source.bytes },
|
{ start: 0, end: value.source.bytes },
|
||||||
@@ -396,3 +398,153 @@ describe.skipIf(process.platform !== "linux" || process.arch !== "x64")(
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
describe.skipIf(process.platform !== "linux" || process.arch !== "x64")(
|
||||||
|
"Wakaru multi-input ownership",
|
||||||
|
() => {
|
||||||
|
it.each(["cafe\u0301.js", "chunk\\name.js"])(
|
||||||
|
"rejects an unpublishable native filename %s before launching",
|
||||||
|
async (name) => {
|
||||||
|
const fixture = await recoveryFixture();
|
||||||
|
const additional = join(fixture.root, name);
|
||||||
|
await writeFile(additional, "export const value = 1;");
|
||||||
|
const result = await fixture.service.recover({
|
||||||
|
path: fixture.path,
|
||||||
|
additional_paths: [additional],
|
||||||
|
output_directory: fixture.output,
|
||||||
|
});
|
||||||
|
if (result.ok)
|
||||||
|
throw new Error("expected unsupported input-layout failure");
|
||||||
|
expect(projectAnalysisError(result.error).code).toBe("invalid_request");
|
||||||
|
expect(JSON.stringify(projectAnalysisError(result.error))).toContain(
|
||||||
|
"Cannot preserve selected input path",
|
||||||
|
);
|
||||||
|
expect(fixture.launches).toHaveLength(0);
|
||||||
|
await expect(access(fixture.output)).rejects.toMatchObject({
|
||||||
|
code: "ENOENT",
|
||||||
|
});
|
||||||
|
expect(await readFile(additional, "utf8")).toBe(
|
||||||
|
"export const value = 1;",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
it("keeps separate snapshots, per-file ranges and relative paths for same-named inputs", async () => {
|
||||||
|
const fixture = await recoveryFixture(
|
||||||
|
"normal",
|
||||||
|
"import './nested/selected.js';\n",
|
||||||
|
);
|
||||||
|
const nested = join(fixture.root, "nested");
|
||||||
|
await mkdir(nested);
|
||||||
|
const additional = join(nested, "selected.js");
|
||||||
|
const content = "export const target = '🎯';\n";
|
||||||
|
await writeFile(additional, content);
|
||||||
|
const result = await fixture.service.recover({
|
||||||
|
path: fixture.path,
|
||||||
|
additional_paths: [additional],
|
||||||
|
output_directory: fixture.output,
|
||||||
|
});
|
||||||
|
if (!result.ok) throw result.error;
|
||||||
|
const value = javascriptRecoveryResultSchema.parse(
|
||||||
|
result.value.normalized_result,
|
||||||
|
);
|
||||||
|
const sources = [value.source, ...(value.additional_sources ?? [])];
|
||||||
|
expect(sources.map((source) => source.path)).toEqual([
|
||||||
|
fixture.path,
|
||||||
|
additional,
|
||||||
|
]);
|
||||||
|
expect(sources.map((source) => source.published_copy.path)).toEqual([
|
||||||
|
join(fixture.output, "inputs/selected.js"),
|
||||||
|
join(fixture.output, "inputs/nested/selected.js"),
|
||||||
|
]);
|
||||||
|
expect(await readFile(sources[1]!.published_copy.path, "utf8")).toBe(
|
||||||
|
content,
|
||||||
|
);
|
||||||
|
expect(result.value.subject?.digest.sha256).toBe(value.source.sha256);
|
||||||
|
for (const [index, module] of value.modules.entries()) {
|
||||||
|
const source = sources[index]!;
|
||||||
|
expect(source.published_copy.sha256).toBe(source.sha256);
|
||||||
|
expect(module.provenance).toMatchObject({
|
||||||
|
reported_input: source.snapshot_path,
|
||||||
|
original_path: source.path,
|
||||||
|
original_sha256: source.sha256,
|
||||||
|
byte_ranges: [{ start: 0, end: source.bytes }],
|
||||||
|
context_byte_ranges: [{ start: 0, end: source.bytes }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const manifest = JSON.parse(await readFile(value.manifest.path, "utf8"));
|
||||||
|
expect(manifest.additional_sources).toEqual(value.additional_sources);
|
||||||
|
await assertRecoveryCleanup(fixture.launches);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"secondary-foreign",
|
||||||
|
"secondary-overflow",
|
||||||
|
"secondary-utf8",
|
||||||
|
"secondary-change",
|
||||||
|
"redistribute",
|
||||||
|
])("rejects %s against its own source and rolls back", async (mode) => {
|
||||||
|
const fixture = await recoveryFixture(mode, "a");
|
||||||
|
const additional = join(fixture.root, "chunk.js");
|
||||||
|
await writeFile(additional, mode === "secondary-utf8" ? "🎯" : "bc");
|
||||||
|
const result = await fixture.service.recover({
|
||||||
|
path: fixture.path,
|
||||||
|
additional_paths: [additional],
|
||||||
|
output_directory: fixture.output,
|
||||||
|
});
|
||||||
|
if (result.ok) throw new Error("expected per-source validation failure");
|
||||||
|
expect(projectAnalysisError(result.error).code).toMatch(
|
||||||
|
/execution_failure|unreadable_output/u,
|
||||||
|
);
|
||||||
|
const reason =
|
||||||
|
mode === "secondary-foreign"
|
||||||
|
? "does not bind the selected input snapshot"
|
||||||
|
: mode === "redistribute" || mode === "secondary-change"
|
||||||
|
? "Original input changed"
|
||||||
|
: "Invalid half-open UTF-8 byte range";
|
||||||
|
expect(JSON.stringify(projectAnalysisError(result.error))).toContain(
|
||||||
|
reason,
|
||||||
|
);
|
||||||
|
await expect(access(fixture.output)).rejects.toMatchObject({
|
||||||
|
code: "ENOENT",
|
||||||
|
});
|
||||||
|
await assertRecoveryCleanup(fixture.launches);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects aggregate input overflow before launching the engine", async () => {
|
||||||
|
const fixture = await recoveryFixture("normal", "a");
|
||||||
|
const additional = join(fixture.root, "chunk.js");
|
||||||
|
const handle = await open(additional, "wx");
|
||||||
|
try {
|
||||||
|
await handle.truncate(RECOVERY_LIMITS.inputBytes);
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
|
const result = await fixture.service.recover({
|
||||||
|
path: fixture.path,
|
||||||
|
additional_paths: [additional],
|
||||||
|
output_directory: fixture.output,
|
||||||
|
});
|
||||||
|
if (result.ok) throw new Error("expected aggregate capacity failure");
|
||||||
|
expect(projectAnalysisError(result.error).code).toBe("invalid_request");
|
||||||
|
expect(fixture.launches).toHaveLength(0);
|
||||||
|
await expect(access(fixture.output)).rejects.toMatchObject({
|
||||||
|
code: "ENOENT",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-UTF-8 input before launching recovery", async () => {
|
||||||
|
const fixture = await recoveryFixture();
|
||||||
|
await writeFile(fixture.path, Buffer.from([0xff, 0xfe]));
|
||||||
|
const result = await fixture.service.recover({
|
||||||
|
path: fixture.path,
|
||||||
|
output_directory: fixture.output,
|
||||||
|
});
|
||||||
|
if (result.ok) throw new Error("expected malformed source failure");
|
||||||
|
expect(projectAnalysisError(result.error).code).toBe("invalid_request");
|
||||||
|
expect(fixture.launches).toHaveLength(0);
|
||||||
|
await expect(access(fixture.output)).rejects.toMatchObject({
|
||||||
|
code: "ENOENT",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||||
|
import { writeFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||||
import { expect, it, onTestFinished } from "vitest";
|
import { expect, it, onTestFinished } from "vitest";
|
||||||
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||||
@@ -10,9 +12,11 @@ import {
|
|||||||
recoveryFixture,
|
recoveryFixture,
|
||||||
} from "../../fixtures/javascript-recovery/provider.js";
|
} from "../../fixtures/javascript-recovery/provider.js";
|
||||||
|
|
||||||
it.skipIf(process.platform !== "linux" || process.arch !== "x64")(
|
it
|
||||||
"advertises and executes recovery through the named SDK contract without a binary target",
|
.skipIf(process.platform !== "linux" || process.arch !== "x64")
|
||||||
async () => {
|
.each([false, true])(
|
||||||
|
"advertises and executes recovery through the named SDK contract (multiple inputs: %s)",
|
||||||
|
async (multiple) => {
|
||||||
const fixture = await recoveryFixture();
|
const fixture = await recoveryFixture();
|
||||||
const session = createTestBinarySession(() => {
|
const session = createTestBinarySession(() => {
|
||||||
throw new Error("no binary provider should start");
|
throw new Error("no binary provider should start");
|
||||||
@@ -44,7 +48,13 @@ it.skipIf(process.platform !== "linux" || process.arch !== "x64")(
|
|||||||
const outputSchema: Record<string, unknown> = advertised.outputSchema;
|
const outputSchema: Record<string, unknown> = advertised.outputSchema;
|
||||||
expect(ajv.validateSchema(inputSchema)).toBe(true);
|
expect(ajv.validateSchema(inputSchema)).toBe(true);
|
||||||
expect(ajv.validateSchema(outputSchema)).toBe(true);
|
expect(ajv.validateSchema(outputSchema)).toBe(true);
|
||||||
const args = { path: fixture.path, output_directory: fixture.output };
|
const additional = join(fixture.root, "chunk.js");
|
||||||
|
if (multiple) await writeFile(additional, "export const chunk = 1;");
|
||||||
|
const args = {
|
||||||
|
path: fixture.path,
|
||||||
|
output_directory: fixture.output,
|
||||||
|
...(multiple ? { additional_paths: [additional] } : {}),
|
||||||
|
};
|
||||||
expect(ajv.validate(inputSchema, args)).toBe(true);
|
expect(ajv.validate(inputSchema, args)).toBe(true);
|
||||||
const response = await client.callTool({
|
const response = await client.callTool({
|
||||||
name: "recover_javascript_sources",
|
name: "recover_javascript_sources",
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ afterEach(async () => {
|
|||||||
await Promise.all(clients.splice(0).map((client) => client.close()));
|
await Promise.all(clients.splice(0).map((client) => client.close()));
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("GhidraClient", () => {
|
describe("GhidraClient captured source snapshot", () => {
|
||||||
it("exposes the immutable import snapshot only during the live session", async () => {
|
it("exposes the immutable import snapshot only during the live session", async () => {
|
||||||
const client = clientFor(new FixtureLauncher());
|
const client = clientFor(new FixtureLauncher());
|
||||||
await expect(client.readTargetSnapshot()).resolves.toMatchObject({
|
await expect(client.readTargetSnapshot()).resolves.toMatchObject({
|
||||||
@@ -154,16 +154,43 @@ describe("GhidraClient", () => {
|
|||||||
error: { kind: "protocol" },
|
error: { kind: "protocol" },
|
||||||
});
|
});
|
||||||
await expect(client.start()).resolves.toMatchObject({ ok: true });
|
await expect(client.start()).resolves.toMatchObject({ ok: true });
|
||||||
|
const abortedRead = new AbortController();
|
||||||
|
abortedRead.abort();
|
||||||
|
await expect(
|
||||||
|
client.readTargetSnapshot(1024, abortedRead.signal),
|
||||||
|
).resolves.toMatchObject({ ok: false, error: { kind: "cancelled" } });
|
||||||
const snapshot = await client.readTargetSnapshot();
|
const snapshot = await client.readTargetSnapshot();
|
||||||
expect(snapshot.ok).toBe(true);
|
expect(snapshot.ok).toBe(true);
|
||||||
if (snapshot.ok) expect(snapshot.value).toEqual(readFileSync(fixturePath));
|
const expected = readFileSync(fixturePath);
|
||||||
|
if (snapshot.ok)
|
||||||
|
expect(snapshot.value).toEqual({
|
||||||
|
kind: "captured",
|
||||||
|
bytes: expected,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
client.readTargetSnapshot(expected.length),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
value: { kind: "captured", bytes: expected },
|
||||||
|
});
|
||||||
|
const bounded = await client.readTargetSnapshot(0);
|
||||||
|
expect(bounded).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
value: {
|
||||||
|
kind: "over-capacity",
|
||||||
|
maximumBytes: 0,
|
||||||
|
sourceBytesAtLeast: readFileSync(fixturePath).length,
|
||||||
|
},
|
||||||
|
});
|
||||||
await client.close();
|
await client.close();
|
||||||
await expect(client.readTargetSnapshot()).resolves.toMatchObject({
|
await expect(client.readTargetSnapshot()).resolves.toMatchObject({
|
||||||
ok: false,
|
ok: false,
|
||||||
error: { kind: "protocol" },
|
error: { kind: "protocol" },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GhidraClient", () => {
|
||||||
it("completes an exact, fragmented post-analysis handshake", async () => {
|
it("completes an exact, fragmented post-analysis handshake", async () => {
|
||||||
const launcher = new FixtureLauncher("fragmented");
|
const launcher = new FixtureLauncher("fragmented");
|
||||||
const client = clientFor(launcher);
|
const client = clientFor(launcher);
|
||||||
|
|||||||
@@ -39,3 +39,11 @@ bounded, and address-bearing; it never compares their text with Hopper. The
|
|||||||
callback fixture also proves that an unresolved targetless flow is not silently
|
callback fixture also proves that an unresolved targetless flow is not silently
|
||||||
promoted to a direct callee. Every target runs in a separate owned project and
|
promoted to a direct callee. Every target runs in a separate owned project and
|
||||||
must leave no process, socket, project, or runtime root after close.
|
must leave no process, socket, project, or runtime root after close.
|
||||||
|
|
||||||
|
`relative-switch-signed.S` covers signed halfword offsets, the CBZ zero-entry
|
||||||
|
route, and selector biases of 0, 2 and 10 in the AArch64 jump-table lane. The
|
||||||
|
nonzero biases exercise overlapping and disjoint typed label/index ranges. REA
|
||||||
|
retains Ghidra's typed labels when the compared selector has an earlier register
|
||||||
|
definition; additional labels remain unknown until their relationship to the
|
||||||
|
typed switch selector can be proved. The oracle rejects extra numeric labels
|
||||||
|
and verifies each retained target against the source instruction immediate.
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Hand-written signed halfword relative dispatch, modelled on the AOSP
|
||||||
|
// RenderScript blend kernel shape reported in issue #487:
|
||||||
|
// adrp/add table; cmp; b.cs default; ldrsh; cbz null; adr base; add; br.
|
||||||
|
// Entries are signed byte offsets from Lsigned_base (no scaling), so cases
|
||||||
|
// before the base encode negative offsets. A zero entry means "no case body"
|
||||||
|
// and is routed through the CBZ before the branch. The source labels and the
|
||||||
|
// immediates below are the oracle; no REA or Ghidra result defines them.
|
||||||
|
// case 0 -> 200, 1 -> 201, 2 -> 202, 3 -> null path (250), 4 -> 204,
|
||||||
|
// selector >= 5 -> default (-1).
|
||||||
|
#ifndef REA_SELECTOR_BIAS
|
||||||
|
#define REA_SELECTOR_BIAS 0
|
||||||
|
#endif
|
||||||
|
// Nonzero variants switch on the original selector (bias .. bias+4),
|
||||||
|
// while the guarded table index is selector - bias.
|
||||||
|
#if defined(__APPLE__)
|
||||||
|
.section __TEXT,__text,regular,pure_instructions
|
||||||
|
.globl _rea_relative_switch_signed
|
||||||
|
.p2align 2
|
||||||
|
_rea_relative_switch_signed:
|
||||||
|
#else
|
||||||
|
.text
|
||||||
|
.globl rea_relative_switch_signed
|
||||||
|
.type rea_relative_switch_signed,%function
|
||||||
|
.p2align 2
|
||||||
|
rea_relative_switch_signed:
|
||||||
|
#endif
|
||||||
|
#if defined(__APPLE__)
|
||||||
|
adrp x5, Lsigned_table@PAGE
|
||||||
|
add x5, x5, Lsigned_table@PAGEOFF
|
||||||
|
#else
|
||||||
|
adrp x5, Lsigned_table
|
||||||
|
add x5, x5, :lo12:Lsigned_table
|
||||||
|
#endif
|
||||||
|
#if REA_SELECTOR_BIAS != 0
|
||||||
|
sub w8, w0, #REA_SELECTOR_BIAS
|
||||||
|
cmp w8, #5
|
||||||
|
b.cs Lsigned_default
|
||||||
|
ldrsh x6, [x5, w8, uxtw #1]
|
||||||
|
#else
|
||||||
|
cmp w0, #5
|
||||||
|
b.cs Lsigned_default
|
||||||
|
ldrsh x6, [x5, w0, uxtw #1]
|
||||||
|
#endif
|
||||||
|
cbz x6, Lsigned_null
|
||||||
|
adr x5, Lsigned_base
|
||||||
|
add x6, x5, x6
|
||||||
|
br x6
|
||||||
|
Lsigned_case0:
|
||||||
|
mov w0, #200
|
||||||
|
ret
|
||||||
|
Lsigned_case1:
|
||||||
|
mov w0, #201
|
||||||
|
// The base sits inside case 1 so that no real case has a zero offset.
|
||||||
|
Lsigned_base:
|
||||||
|
ret
|
||||||
|
Lsigned_case2:
|
||||||
|
mov w0, #202
|
||||||
|
ret
|
||||||
|
Lsigned_case4:
|
||||||
|
mov w0, #204
|
||||||
|
ret
|
||||||
|
Lsigned_null:
|
||||||
|
mov w0, #250
|
||||||
|
ret
|
||||||
|
Lsigned_default:
|
||||||
|
mov w0, #-1
|
||||||
|
ret
|
||||||
|
#if !defined(__APPLE__)
|
||||||
|
.size rea_relative_switch_signed, .-rea_relative_switch_signed
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(__APPLE__)
|
||||||
|
.section __TEXT,__const
|
||||||
|
#else
|
||||||
|
.section .rodata
|
||||||
|
#endif
|
||||||
|
.p2align 1
|
||||||
|
Lsigned_table:
|
||||||
|
.short Lsigned_case0-Lsigned_base
|
||||||
|
.short Lsigned_case1-Lsigned_base
|
||||||
|
.short Lsigned_case2-Lsigned_base
|
||||||
|
.short 0
|
||||||
|
.short Lsigned_case4-Lsigned_base
|
||||||
+65
@@ -14,6 +14,71 @@ if (args.includes("--version")) {
|
|||||||
} else {
|
} else {
|
||||||
const input = args[0];
|
const input = args[0];
|
||||||
const output = args[args.indexOf("-o") + 1];
|
const output = args[args.indexOf("-o") + 1];
|
||||||
|
const inputs = args.slice(
|
||||||
|
0,
|
||||||
|
args.findIndex((arg) => arg.startsWith("--")),
|
||||||
|
);
|
||||||
|
if (inputs.length > 1) {
|
||||||
|
const modules = [];
|
||||||
|
const provenance = {
|
||||||
|
format: "unknown",
|
||||||
|
strategy: "structural",
|
||||||
|
modules: {},
|
||||||
|
};
|
||||||
|
for (const [index, selected] of inputs.entries()) {
|
||||||
|
const bytes = await readFile(selected);
|
||||||
|
const filename = `module-${index}.js`;
|
||||||
|
modules.push({ filename, kind: "javascript", status: "decompiled" });
|
||||||
|
await writeFile(join(output, filename), bytes);
|
||||||
|
provenance.modules[filename] = {
|
||||||
|
input:
|
||||||
|
index === 1 && mode === "secondary-foreign"
|
||||||
|
? "/unselected.js"
|
||||||
|
: selected,
|
||||||
|
ranges: [
|
||||||
|
[
|
||||||
|
0,
|
||||||
|
index === 1 && mode === "secondary-overflow"
|
||||||
|
? bytes.length + 1
|
||||||
|
: bytes.length,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
context_ranges: [
|
||||||
|
[0, index === 1 && mode === "secondary-utf8" ? 2 : bytes.length],
|
||||||
|
],
|
||||||
|
extraction: "structural",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (mode === "redistribute") {
|
||||||
|
const primary = process.env.REA_RECOVERY_ORIGINAL_PATH;
|
||||||
|
const secondary = process.env.REA_RECOVERY_ADDITIONAL_PATH;
|
||||||
|
const first = await readFile(primary);
|
||||||
|
const second = await readFile(secondary);
|
||||||
|
await writeFile(primary, Buffer.concat([first, second.subarray(0, 1)]));
|
||||||
|
await writeFile(secondary, second.subarray(1));
|
||||||
|
}
|
||||||
|
if (mode === "secondary-change")
|
||||||
|
await writeFile(
|
||||||
|
process.env.REA_RECOVERY_ADDITIONAL_PATH,
|
||||||
|
"changed additional input",
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
join(output, "provenance.json"),
|
||||||
|
JSON.stringify(provenance),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
JSON.stringify({
|
||||||
|
detected_formats: [],
|
||||||
|
safety: "normal",
|
||||||
|
modules,
|
||||||
|
warnings: [],
|
||||||
|
total: modules.length,
|
||||||
|
failed: 0,
|
||||||
|
elapsed_ms: 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
const bytes = await readFile(input);
|
const bytes = await readFile(input);
|
||||||
await writeFile(join(dirname(output), "recovery-started"), "started");
|
await writeFile(join(dirname(output), "recovery-started"), "started");
|
||||||
if (mode === "hang") await new Promise(() => setInterval(() => {}, 1000));
|
if (mode === "hang") await new Promise(() => setInterval(() => {}, 1000));
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export const recoveryFixture = async (
|
|||||||
...options.env,
|
...options.env,
|
||||||
REA_RECOVERY_FIXTURE_MODE: mode,
|
REA_RECOVERY_FIXTURE_MODE: mode,
|
||||||
REA_RECOVERY_ORIGINAL_PATH: path,
|
REA_RECOVERY_ORIGINAL_PATH: path,
|
||||||
|
REA_RECOVERY_ADDITIONAL_PATH: join(root, "chunk.js"),
|
||||||
REA_RECOVERY_ENGINE_PATH: command,
|
REA_RECOVERY_ENGINE_PATH: command,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Vendored
+91
@@ -0,0 +1,91 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
|
const IMAGE_BASE = 0x140000000n;
|
||||||
|
const address = (rva: number): bigint => IMAGE_BASE + BigInt(rva);
|
||||||
|
|
||||||
|
export const nativeAotPeDigest = (bytes: Buffer): string =>
|
||||||
|
createHash("sha256").update(bytes).digest("hex");
|
||||||
|
|
||||||
|
export const nativeAotPeFixture = (major = 9, minor = 1): Buffer => {
|
||||||
|
const bytes = Buffer.alloc(0xa00);
|
||||||
|
bytes.write("MZ", 0, "ascii");
|
||||||
|
bytes.writeUInt32LE(0x80, 0x3c);
|
||||||
|
bytes.write("PE\0\0", 0x80, "ascii");
|
||||||
|
bytes.writeUInt16LE(0x8664, 0x84);
|
||||||
|
bytes.writeUInt16LE(2, 0x86);
|
||||||
|
bytes.writeUInt16LE(0xf0, 0x94);
|
||||||
|
const optional = 0x98;
|
||||||
|
bytes.writeUInt16LE(0x20b, optional);
|
||||||
|
bytes.writeBigUInt64LE(IMAGE_BASE, optional + 24);
|
||||||
|
bytes.writeUInt32LE(0x3000, optional + 56);
|
||||||
|
bytes.writeUInt32LE(0x400, optional + 60);
|
||||||
|
const sectionTable = optional + 0xf0;
|
||||||
|
bytes.write(".text", sectionTable, "ascii");
|
||||||
|
bytes.writeUInt32LE(0x200, sectionTable + 8);
|
||||||
|
bytes.writeUInt32LE(0x1000, sectionTable + 12);
|
||||||
|
bytes.writeUInt32LE(0x200, sectionTable + 16);
|
||||||
|
bytes.writeUInt32LE(0x400, sectionTable + 20);
|
||||||
|
bytes.writeUInt32LE(0x60000020, sectionTable + 36);
|
||||||
|
const data = sectionTable + 40;
|
||||||
|
bytes.write(".rdata", data, "ascii");
|
||||||
|
bytes.writeUInt32LE(0x400, data + 8);
|
||||||
|
bytes.writeUInt32LE(0x2000, data + 12);
|
||||||
|
bytes.writeUInt32LE(0x400, data + 16);
|
||||||
|
bytes.writeUInt32LE(0x600, data + 20);
|
||||||
|
bytes.writeUInt32LE(0x40000040, data + 36);
|
||||||
|
|
||||||
|
bytes.writeUInt8(0xc3, 0x410);
|
||||||
|
bytes.writeUInt8(0xc3, 0x420);
|
||||||
|
bytes.writeUInt8(0xc3, 0x430);
|
||||||
|
const header = 0x600;
|
||||||
|
bytes.writeUInt32LE(0x00525452, header);
|
||||||
|
bytes.writeUInt16LE(major, header + 4);
|
||||||
|
bytes.writeUInt16LE(minor, header + 6);
|
||||||
|
bytes.writeUInt16LE(2, header + 12);
|
||||||
|
bytes.writeUInt8(24, header + 14);
|
||||||
|
bytes.writeUInt8(1, header + 15);
|
||||||
|
bytes.writeUInt32LE(207, header + 16);
|
||||||
|
bytes.writeUInt32LE(1, header + 20);
|
||||||
|
bytes.writeBigUInt64LE(address(0x2100), header + 24);
|
||||||
|
bytes.writeBigUInt64LE(address(0x2105), header + 32);
|
||||||
|
bytes.writeUInt32LE(206, header + 40);
|
||||||
|
bytes.writeBigUInt64LE(address(0x2300), header + 48);
|
||||||
|
bytes.writeBigUInt64LE(address(0x2380), header + 56);
|
||||||
|
bytes.writeInt32LE(0x280, 0x700);
|
||||||
|
bytes.writeUInt8(0x09, 0x704);
|
||||||
|
|
||||||
|
const writeMethodTable = (
|
||||||
|
rva: number,
|
||||||
|
flags: number,
|
||||||
|
baseSize: number,
|
||||||
|
related: bigint,
|
||||||
|
slots: readonly bigint[],
|
||||||
|
) => {
|
||||||
|
const offset = 0x600 + rva - 0x2000;
|
||||||
|
bytes.writeUInt32LE(flags, offset);
|
||||||
|
bytes.writeUInt32LE(baseSize, offset + 4);
|
||||||
|
bytes.writeBigUInt64LE(related, offset + 8);
|
||||||
|
bytes.writeUInt16LE(slots.length, offset + 16);
|
||||||
|
bytes.writeUInt16LE(0, offset + 18);
|
||||||
|
bytes.writeUInt32LE(0x1234, offset + 20);
|
||||||
|
slots.forEach((slot, index) =>
|
||||||
|
bytes.writeBigUInt64LE(slot, offset + 24 + index * 8),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const object = address(0x2200);
|
||||||
|
writeMethodTable(0x2200, 0x50000000, 0x18, 0n, [
|
||||||
|
address(0x1010),
|
||||||
|
address(0x1020),
|
||||||
|
address(0x1030),
|
||||||
|
]);
|
||||||
|
writeMethodTable(0x2280, 0x50000000, 0x20, object, [address(0x1040)]);
|
||||||
|
writeMethodTable(0x22c0, 0x50000000, 0x16, object, []);
|
||||||
|
|
||||||
|
const frozen = 0x600 + 0x2300 - 0x2000;
|
||||||
|
bytes.writeBigUInt64LE(address(0x22c0), frozen);
|
||||||
|
const value = Buffer.from("REA_NATIVEAOT_FROZEN", "utf16le");
|
||||||
|
bytes.writeUInt32LE(value.length / 2, frozen + 8);
|
||||||
|
value.copy(bytes, frozen + 12);
|
||||||
|
bytes.writeUInt16LE(0, frozen + 12 + value.length);
|
||||||
|
return bytes;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user