Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7aee9900da | ||
|
|
e1def2bf1a |
@@ -1,13 +1,5 @@
|
||||
# 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
|
||||
extracted tree. When size is unknown or large and the connected schema advertises
|
||||
`detail`, request `detail: "summary"`. This returns an `inspect_analysis_view`
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
**See a feature you like. Understand how it works, down to the binary level.**
|
||||
|
||||
[](https://www.npmjs.com/package/rea-agents)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](docs/mcp-contracts.md#generated-catalog)
|
||||
[](#what-you-can-analyze)
|
||||
[](https://skills.sh/morluto/rea/reverse-engineer-anything)
|
||||
|
||||
+183
-388
@@ -2004,20 +2004,9 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
);
|
||||
JumpTable.LoadTable[] loadTables = jumpTable.getLoadTables();
|
||||
RecoveredAArch64RelativeTable recoveredAArch64 =
|
||||
recoverAArch64RelativeTable(jumpTable);
|
||||
if (recoveredAArch64 != null && loadTables.length > 0) {
|
||||
// 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;
|
||||
}
|
||||
loadTables.length == 0
|
||||
? recoverAArch64RelativeTable(jumpTable)
|
||||
: null;
|
||||
List<Address> referencedDataAddresses = loadTables.length == 0
|
||||
? dispatchPathDataReferences(jumpTable)
|
||||
: List.of();
|
||||
@@ -2051,7 +2040,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
source.add("evidence", evidence);
|
||||
dataSources.add(source);
|
||||
}
|
||||
if (recoveredAArch64 != null && loadTables.length == 0) {
|
||||
if (recoveredAArch64 != null) {
|
||||
JsonObject source = new JsonObject();
|
||||
source.addProperty(
|
||||
"address",
|
||||
@@ -2066,16 +2055,9 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
inferenceEvidence(
|
||||
"jump-table",
|
||||
"ghidra-instruction-and-memory",
|
||||
"Decoded " + recoveredAArch64.mappings.size() + " " +
|
||||
(recoveredAArch64.signedEntries ? "signed" : "unsigned") +
|
||||
"Decoded " + recoveredAArch64.mappings.size() +
|
||||
" entries of size " + recoveredAArch64.entrySize + " bytes from " + canonicalAddress(recoveredAArch64.tableAddress) +
|
||||
"; 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) + ".")
|
||||
"; the AArch64 LDRB-or-LDRH/ADR/ADD/BR chain uses a selector guarded by an unsigned upper-bound branch."
|
||||
)
|
||||
);
|
||||
source.add("evidence", evidence);
|
||||
@@ -2114,33 +2096,6 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
result.add("data_sources", dataSources);
|
||||
Address[] targets = jumpTable.getCases();
|
||||
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 defaultTargets = new JsonArray();
|
||||
for (Address target : typedCases.defaults) {
|
||||
@@ -2148,30 +2103,27 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
}
|
||||
Set<Address> coveredTargets = new HashSet<>(typedCases.cases.values());
|
||||
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) {
|
||||
for (int index = 0; index < recoveredAArch64.mappings.size(); index += 1) {
|
||||
monitor.checkCancelled();
|
||||
RecoveredAArch64Mapping recovered = recoveredAArch64.mappings.get(index);
|
||||
if (
|
||||
typedCases.unknownTargets.contains(recovered.targetAddress) ||
|
||||
typedCases.ambiguousValues.contains((long) recovered.tableIndex)
|
||||
typedCases.ambiguousValues.contains((long) recovered.caseValue)
|
||||
) 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();
|
||||
mapping.addProperty("case_value", recovered.tableIndex);
|
||||
mapping.addProperty("case_value", recovered.caseValue);
|
||||
String targetAddress = canonicalAddress(recovered.targetAddress);
|
||||
mapping.addProperty("target_address", targetAddress);
|
||||
mapping.addProperty("confidence", "high");
|
||||
@@ -2180,18 +2132,14 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
inferenceEvidence(
|
||||
"jump-table",
|
||||
"ghidra-instruction-and-memory",
|
||||
"Case value " + recovered.tableIndex + (recoveredAArch64.entrySize == 1 ? " indexes byte " : " indexes halfword at byte offset ") +
|
||||
(recovered.tableIndex * recoveredAArch64.entrySize) + " at " + canonicalAddress(recoveredAArch64.tableAddress) +
|
||||
(recovered.nullEntry
|
||||
? "; 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 + ".")
|
||||
"Case value " + recovered.caseValue + (recoveredAArch64.entrySize == 1 ? " indexes byte " : " indexes halfword at byte offset ") +
|
||||
(recovered.caseValue * recoveredAArch64.entrySize) + " at " + canonicalAddress(recoveredAArch64.tableAddress) +
|
||||
"; the loaded unsigned entry selects a four-byte instruction offset from " +
|
||||
canonicalAddress(recoveredAArch64.branchBase) + ", reaching " + targetAddress + "."
|
||||
)
|
||||
);
|
||||
mapping.add("evidence", evidence);
|
||||
if (!typedCases.cases.containsKey((long) recovered.tableIndex)) {
|
||||
if (!typedCases.cases.containsKey((long) recovered.caseValue)) {
|
||||
mappings.add(mapping);
|
||||
}
|
||||
coveredTargets.add(recovered.targetAddress);
|
||||
@@ -2265,7 +2213,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
);
|
||||
}
|
||||
if (recoveredAArch64 == null && loadTables.length == 0 && currentProgram.getLanguageID().getIdAsString().startsWith("AARCH64:")) {
|
||||
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.");
|
||||
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.");
|
||||
}
|
||||
if (targets.length == 0) {
|
||||
limitations.add(
|
||||
@@ -2395,21 +2343,12 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
}
|
||||
|
||||
private static final class RecoveredAArch64Mapping {
|
||||
private final int tableIndex;
|
||||
private final int caseValue;
|
||||
private final Address targetAddress;
|
||||
private final long encodedEntry;
|
||||
private final boolean nullEntry;
|
||||
|
||||
private RecoveredAArch64Mapping(
|
||||
int tableIndex,
|
||||
Address targetAddress,
|
||||
long encodedEntry,
|
||||
boolean nullEntry
|
||||
) {
|
||||
this.tableIndex = tableIndex;
|
||||
private RecoveredAArch64Mapping(int caseValue, Address targetAddress) {
|
||||
this.caseValue = caseValue;
|
||||
this.targetAddress = targetAddress;
|
||||
this.encodedEntry = encodedEntry;
|
||||
this.nullEntry = nullEntry;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2417,70 +2356,24 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
private final Address tableAddress;
|
||||
private final Address branchBase;
|
||||
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 RecoveredAArch64RelativeTable(
|
||||
Address tableAddress,
|
||||
Address branchBase,
|
||||
int entrySize,
|
||||
Address priorSelectorDefinition,
|
||||
boolean signedEntries,
|
||||
int offsetShift,
|
||||
String guardMnemonic,
|
||||
long compareImmediate,
|
||||
Address nullTarget,
|
||||
Address unreachableZeroTarget,
|
||||
List<RecoveredAArch64Mapping> mappings
|
||||
) {
|
||||
this.tableAddress = tableAddress;
|
||||
this.branchBase = branchBase;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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 directly verified AArch64 relative branch tables:
|
||||
* 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.
|
||||
* Recover only the directly verified AArch64 byte/halfword-indexed relative branch
|
||||
* table form. Other instruction sequences keep their case labels unknown.
|
||||
*/
|
||||
private RecoveredAArch64RelativeTable recoverAArch64RelativeTable(
|
||||
JumpTable jumpTable
|
||||
@@ -2502,265 +2395,167 @@ public final class ReaGhidraBridge extends HeadlessScript {
|
||||
if (instruction.getAddress().equals(jumpTable.getSwitchAddress())) break;
|
||||
}
|
||||
int branchIndex = instructions.size() - 1;
|
||||
if (branchIndex < 1 || !mnemonic(instructions.get(branchIndex)).equals("BR")) {
|
||||
if (branchIndex < 1 ||
|
||||
!instructions.get(branchIndex).getMnemonicString().equalsIgnoreCase("BR")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Address[] recoveredTargets = jumpTable.getCases();
|
||||
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)
|
||||
);
|
||||
}
|
||||
for (Address target : recoveredTargets) recoveredTargetSet.add(canonicalAddress(target));
|
||||
List<Address> dataAddresses = 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)));
|
||||
for (int loadIndex = 0; loadIndex < branchIndex; loadIndex += 1) {
|
||||
Instruction load = instructions.get(loadIndex);
|
||||
int entrySize = load.getMnemonicString().equalsIgnoreCase("LDRB") ? 1 : load.getMnemonicString().equalsIgnoreCase("LDRH") ? 2 : 0;
|
||||
if (entrySize == 0 || branchIndex != loadIndex + 2 || loadIndex < 3 ||
|
||||
!instructions.get(loadIndex - 1).getMnemonicString().equalsIgnoreCase("ADR") ||
|
||||
!instructions.get(loadIndex - 2).getMnemonicString().equalsIgnoreCase("ADD") ||
|
||||
!instructions.get(loadIndex - 3).getMnemonicString().equalsIgnoreCase("ADRP")) continue;
|
||||
String loadOperands = operands(load);
|
||||
java.util.regex.Matcher loadMatcher = java.util.regex.Pattern
|
||||
.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\\]$")
|
||||
.matcher(loadOperands);
|
||||
if (!loadMatcher.matches()) continue;
|
||||
String loadedRegister = normalizedRegister(loadOperands.substring(0, loadOperands.indexOf(',')));
|
||||
String tableBaseRegister = normalizedRegister(loadMatcher.group(1));
|
||||
String indexRegister = normalizedRegister(loadMatcher.group(2));
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
int baseIndex = latestWriter(instructions, branchIndex - 1, baseRegister);
|
||||
if (baseIndex < 0 || !mnemonic(instructions.get(baseIndex)).equals("ADR")) return null;
|
||||
java.util.regex.Matcher baseMatcher = AARCH64_REGISTER_AND_ADDRESS.matcher(operands(instructions.get(baseIndex)));
|
||||
if (!baseMatcher.matches()) return null;
|
||||
Address branchBase = addressFromOperand(baseMatcher.group(2));
|
||||
|
||||
int tableAddIndex = latestWriter(instructions, loadIndex, tableBaseRegister);
|
||||
if (tableAddIndex < 0 || !mnemonic(instructions.get(tableAddIndex)).equals("ADD")) return null;
|
||||
java.util.regex.Matcher tableAddMatcher = AARCH64_ADD_IMMEDIATE.matcher(operands(instructions.get(tableAddIndex)));
|
||||
if (!tableAddMatcher.matches() ||
|
||||
!normalizedRegister(tableAddMatcher.group(1)).equals(tableBaseRegister) ||
|
||||
!normalizedRegister(tableAddMatcher.group(2)).equals(tableBaseRegister)) return null;
|
||||
int tablePageIndex = latestWriter(instructions, tableAddIndex, tableBaseRegister);
|
||||
if (tablePageIndex < 0 || !mnemonic(instructions.get(tablePageIndex)).equals("ADRP")) return null;
|
||||
java.util.regex.Matcher pageMatcher = AARCH64_REGISTER_AND_ADDRESS.matcher(operands(instructions.get(tablePageIndex)));
|
||||
if (!pageMatcher.matches() || !normalizedRegister(pageMatcher.group(1)).equals(tableBaseRegister)) return null;
|
||||
Address tableAddress = addressFromOperand(pageMatcher.group(2)).add(parseInteger(tableAddMatcher.group(3)));
|
||||
if (!currentProgram.getMemory().contains(tableAddress) ||
|
||||
(!dataAddresses.isEmpty() && !dataAddresses.contains(tableAddress))) return null;
|
||||
|
||||
// Unsigned guard: CMP Rs, #imm, then B.HI (imm + 1 entries) or
|
||||
// B.CS/B.HS (imm entries), with only flag-preserving moves and
|
||||
// address arithmetic between the compare and the load.
|
||||
int guardIndex = -1;
|
||||
for (int index = loadIndex - 1; index >= 0; index -= 1) {
|
||||
String name = mnemonic(instructions.get(index));
|
||||
if (name.startsWith("B.")) {
|
||||
guardIndex = index;
|
||||
break;
|
||||
}
|
||||
if (!AARCH64_FLAG_PRESERVING.contains(name)) 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;
|
||||
Address resolvedTableAddress = null;
|
||||
for (int index = loadIndex - 3; index < loadIndex - 1; index += 1) {
|
||||
Instruction instruction = instructions.get(index);
|
||||
String mnemonic = instruction.getMnemonicString().toUpperCase(Locale.ROOT);
|
||||
String text = operands(instruction);
|
||||
if (mnemonic.equals("ADRP") &&
|
||||
text.matches("(?i)^[wx]\\d+,\\s*0x[0-9a-f]+$")) {
|
||||
String[] parts = text.split(",");
|
||||
if (normalizedRegister(parts[0]).equals(tableBaseRegister)) {
|
||||
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) ||
|
||||
(!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 -1;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String operands(Instruction instruction) {
|
||||
|
||||
@@ -60,12 +60,6 @@ manifest and graph commitments, JavaScript Application Graph, static
|
||||
Electron summary, reconstruction statistics, and explicit limitations. It does
|
||||
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 inventory checks Electron integrity metadata for embedded archive entries
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# JavaScript source recovery
|
||||
|
||||
`recover_javascript_sources` / `recover-javascript-sources` derives readable
|
||||
modules from explicitly selected UTF-8 JavaScript files or bundles. Use it on
|
||||
modules from one explicitly selected UTF-8 JavaScript file or bundle. Use it on
|
||||
files returned by `export_web_scripts` or other local scripts. The operation
|
||||
never executes the selected or recovered application code.
|
||||
|
||||
@@ -52,58 +52,35 @@ Both give the same binary (SHA-256
|
||||
```sh
|
||||
rea recover-javascript-sources /tmp/web-scripts/app.js /tmp/recovered-app --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:
|
||||
|
||||
```json
|
||||
{
|
||||
"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"
|
||||
"path": "/tmp/web-scripts/app.js",
|
||||
"output_directory": "/tmp/recovered-app"
|
||||
}
|
||||
```
|
||||
|
||||
The output directory must be absent, absolute, and have an existing parent.
|
||||
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
|
||||
The source is snapshotted in a private workspace, and the engine writes only to
|
||||
its own staging directory. Reported module paths, input identities, UTF-8 byte
|
||||
ranges, regular files and emitted map representations are validated before
|
||||
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
|
||||
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:
|
||||
|
||||
- `inputs/bundle.js`: exact original snapshot for a single input. Multiple inputs
|
||||
retain paths relative to their common parent under `inputs/`.
|
||||
- `inputs/bundle.js`: exact original snapshot; its digest binds the Evidence.
|
||||
- `modules/`: derived modules and any upstream-emitted source maps.
|
||||
- `reports/stdout.json` and `reports/provenance.json`: exact producer reports.
|
||||
- `manifest.json`: identities, modules, transform choices, warnings and limitations.
|
||||
|
||||
The inline result includes complete file paths/digests/sizes, extraction ranges,
|
||||
warnings and `analysis_input`, directly usable as the arguments to
|
||||
`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,
|
||||
`analyze_javascript_application`. Raw Evidence retains selected command arguments,
|
||||
reported input paths, exit status, stderr and reports; temporary paths are recorded
|
||||
as historical execution coordinates after their workspace is removed.
|
||||
|
||||
@@ -130,8 +107,7 @@ equivalence remain unknown. No network fetching occurs.
|
||||
## Resource policy and verification
|
||||
|
||||
Each operation uses one worker, a 1 GiB address-space ceiling and a 120-second
|
||||
deadline; it accepts up to 64 MiB of total input across all selected files and
|
||||
128 MiB/10000 entries of staged
|
||||
deadline; it accepts up to 64 MiB of input and 128 MiB/10000 entries of staged
|
||||
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
|
||||
host containment across concurrent callers.
|
||||
|
||||
+10
-13
@@ -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
|
||||
prove behavior on the verified Ghidra 12.1.4 and JDK 21 build.
|
||||
|
||||
| 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: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: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; 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: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 |
|
||||
| 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: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: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: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 |
|
||||
|
||||
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
|
||||
@@ -1036,10 +1036,7 @@ 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.
|
||||
The lane compiles source-owned fixtures, exercises CLI and stdio MCP, verifies
|
||||
published bytes and provenance, feeds recovered modules into existing analysis,
|
||||
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
|
||||
and compares a finite set of known fixture results. The provider also accepts
|
||||
other ^1.13.0 releases and reports them as unverified; this lane proves the
|
||||
audited release. It does not establish
|
||||
arbitrary recovered-application equivalence. CI installs these prerequisites only
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
@@ -20,56 +20,23 @@ export async function buildRecoveryFixtures(root, prefix) {
|
||||
const source = fileURLToPath(new URL("./entry.mjs", import.meta.url));
|
||||
const webpackOutput = join(root, "webpack");
|
||||
await mkdir(webpackOutput);
|
||||
const compileWebpack = async (options) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const compiler = webpack(options);
|
||||
compiler.run((error, stats) => {
|
||||
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 new Promise((resolve, reject) => {
|
||||
const compiler = webpack({
|
||||
mode: "production",
|
||||
entry: source,
|
||||
parallelism: 1,
|
||||
optimization: { concatenateModules: false },
|
||||
output: { path: webpackOutput, filename: "bundle.js" },
|
||||
});
|
||||
compiler.run((error, stats) => {
|
||||
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");
|
||||
await esbuild.build({
|
||||
entryPoints: [source],
|
||||
@@ -128,13 +95,6 @@ export async function buildRecoveryFixtures(root, prefix) {
|
||||
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 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
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));
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import { formatPrice } from "./shared.mjs";
|
||||
console.log(formatPrice(1));
|
||||
import("./checkout.mjs").then(({ checkout }) => console.log(checkout([1, 2])));
|
||||
@@ -1,14 +0,0 @@
|
||||
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;
|
||||
@@ -176,9 +176,6 @@ const startStorageSite = async () => {
|
||||
origin: `http://127.0.0.1:${port}`,
|
||||
close: async () => {
|
||||
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");
|
||||
},
|
||||
};
|
||||
|
||||
@@ -277,13 +277,11 @@ try {
|
||||
throw new Error("Scenario attachment terminated its external browser");
|
||||
|
||||
const profilesBefore = await scenarioProfiles();
|
||||
process.stderr.write("Browser verifier: scenario environment\n");
|
||||
const scenarioEnvironment = await verifyScenarioEnvironment(
|
||||
endpoint,
|
||||
target,
|
||||
site.origin,
|
||||
);
|
||||
process.stderr.write("Browser verifier: scenario storage\n");
|
||||
const scenarioStorage = await verifyScenarioStorage({
|
||||
executable,
|
||||
endpoint,
|
||||
@@ -341,9 +339,7 @@ try {
|
||||
`Browser scenario included unselected structured value: ${unselectedValue}`,
|
||||
);
|
||||
|
||||
process.stderr.write("Browser verifier: large screenshot\n");
|
||||
const largeScreenshot = await verifyLargeScreenshotE2e(endpoint, site.origin);
|
||||
process.stderr.write("Browser verifier: popup coverage\n");
|
||||
const popupEvents = await verifyPopupEventCoverage(executable);
|
||||
const networkEvidence = await verifyBrowserNetworkEvidence(
|
||||
executable,
|
||||
|
||||
@@ -674,113 +674,6 @@ 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) {
|
||||
const entry = requireProcedure(procedures, "rea_ghidra_inventory_entry");
|
||||
const names = await inventoryCall(client, "list_names", {
|
||||
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
verifyJumpTableDenseSwitch,
|
||||
verifyNativeTypeLayout,
|
||||
verifyRelativeSwitch,
|
||||
verifyRelativeSignedSwitch,
|
||||
verifyNativeValueTrace,
|
||||
} from "./verify-real-ghidra-function.mjs";
|
||||
const exec = promisify(execFile);
|
||||
@@ -174,36 +173,6 @@ 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, [
|
||||
"--target=aarch64-linux-gnu",
|
||||
@@ -553,30 +522,25 @@ async function verifyTarget(targetPath, variant, expectedTarget = null) {
|
||||
client,
|
||||
})
|
||||
: null;
|
||||
const probes = variant.startsWith("relative-signed-")
|
||||
? await verifyRelativeSignedSwitch(
|
||||
const probes = variant.startsWith("relative-")
|
||||
? await verifyRelativeSwitch(
|
||||
client,
|
||||
Number(variant.split("-").at(-1)),
|
||||
procedures,
|
||||
Number(variant.slice(-1)),
|
||||
)
|
||||
: variant.startsWith("relative-")
|
||||
? await verifyRelativeSwitch(
|
||||
client,
|
||||
procedures,
|
||||
Number(variant.slice(-1)),
|
||||
)
|
||||
: variant === "type-layout"
|
||||
? await verifyNativeTypeLayout(client, names)
|
||||
: variant === "jump-table"
|
||||
? await verifyJumpTableDenseSwitch(client, procedures)
|
||||
: variant === "custom" || variant === "aarch64-jump-table"
|
||||
? null
|
||||
: await verifyInventoryOperations({
|
||||
client,
|
||||
variant,
|
||||
procedures,
|
||||
names,
|
||||
strings,
|
||||
});
|
||||
: variant === "type-layout"
|
||||
? await verifyNativeTypeLayout(client, names)
|
||||
: variant === "jump-table"
|
||||
? await verifyJumpTableDenseSwitch(client, procedures)
|
||||
: variant === "custom" || variant === "aarch64-jump-table"
|
||||
? null
|
||||
: await verifyInventoryOperations({
|
||||
client,
|
||||
variant,
|
||||
procedures,
|
||||
names,
|
||||
strings,
|
||||
});
|
||||
const nativeValues =
|
||||
variant === "debug"
|
||||
? await verifyNativeValueTrace(client, procedures, parsedTarget.value)
|
||||
|
||||
@@ -56,7 +56,7 @@ try {
|
||||
process.env.REA_JAVASCRIPT_FIXTURE_TOOLS,
|
||||
);
|
||||
await client.connect(transport);
|
||||
for (const fixture of [...fixtures.files, fixtures.codeSplit]) {
|
||||
for (const fixture of fixtures.files) {
|
||||
for (const surface of ["cli", "stdio_mcp"]) {
|
||||
const output = join(root, `${fixture.name}-${surface}`);
|
||||
const flags =
|
||||
@@ -75,18 +75,11 @@ try {
|
||||
fixture.path,
|
||||
output,
|
||||
...flags,
|
||||
...(fixture.additional_paths ?? []).flatMap((path) => [
|
||||
"--additional-path",
|
||||
path,
|
||||
]),
|
||||
)
|
||||
: await call("recover_javascript_sources", {
|
||||
path: fixture.path,
|
||||
output_directory: output,
|
||||
...fixture.options,
|
||||
...(fixture.additional_paths
|
||||
? { additional_paths: fixture.additional_paths }
|
||||
: {}),
|
||||
});
|
||||
const result = javascriptRecoveryResultSchema.parse(
|
||||
evidence.normalized_result,
|
||||
@@ -104,25 +97,6 @@ try {
|
||||
analysis.normalized_result.statistics.parsed_javascript_files,
|
||||
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 =
|
||||
result.modules.find(
|
||||
(module) => module.reported_filename === "entry.js",
|
||||
@@ -130,8 +104,7 @@ try {
|
||||
assert.ok(recoveredEntry);
|
||||
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])]));';
|
||||
for (const selected of fixture.additional_paths ||
|
||||
fixture.options?.extraction_mode === "inspection"
|
||||
for (const selected of fixture.options?.extraction_mode === "inspection"
|
||||
? []
|
||||
: [fixture.path, recoveredEntry.artifact.path]) {
|
||||
const execution = await run(
|
||||
@@ -152,9 +125,7 @@ try {
|
||||
maps: result.modules.filter((module) => module.source_map !== null)
|
||||
.length,
|
||||
finite_fixture_oracle:
|
||||
!fixture.additional_paths &&
|
||||
fixture.options?.extraction_mode !== "inspection",
|
||||
cross_chunk_imports: fixture.additional_paths ? imports.length : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -186,15 +157,9 @@ async function assertRecoveredArtifacts(evidence, result, fixture) {
|
||||
assert.equal(result.status, "complete");
|
||||
assert.equal(result.reported_format, fixture.expectedFormat);
|
||||
assert.equal(result.modules.length, fixture.expectedModules);
|
||||
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)));
|
||||
assert.equal(result.source.sha256, fingerprint(await readFile(fixture.path)));
|
||||
for (const file of [
|
||||
...sources.map((source) => source.published_copy),
|
||||
result.source.published_copy,
|
||||
result.report,
|
||||
result.provenance,
|
||||
result.manifest,
|
||||
@@ -208,27 +173,16 @@ async function assertRecoveredArtifacts(evidence, result, fixture) {
|
||||
assert.equal(fingerprint(bytes), file.sha256);
|
||||
}
|
||||
for (const module of result.modules) {
|
||||
const source = sources.find(
|
||||
(source) => source.snapshot_path === module.provenance.reported_input,
|
||||
);
|
||||
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);
|
||||
assert.equal(module.provenance.original_sha256, result.source.sha256);
|
||||
assert.equal(module.provenance.reported_input, result.source.snapshot_path);
|
||||
for (const range of module.provenance.byte_ranges)
|
||||
assert.ok(range.start <= range.end && range.end <= result.source.bytes);
|
||||
if (module.source_map) {
|
||||
const map = JSON.parse(await readFile(module.source_map.path, "utf8"));
|
||||
for (const source of map.sources)
|
||||
assert.ok(
|
||||
sources.some(
|
||||
(selected) =>
|
||||
selected.published_copy.path ===
|
||||
resolve(dirname(module.source_map.path), source),
|
||||
),
|
||||
"Map source must name a published input snapshot",
|
||||
assert.equal(
|
||||
resolve(dirname(module.source_map.path), source),
|
||||
result.source.published_copy.path,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
staticInferenceEvidence,
|
||||
} from "./JavaScriptArtifactGraphEvidence.js";
|
||||
import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js";
|
||||
import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js";
|
||||
|
||||
interface PackageRoleInput {
|
||||
readonly packageNode: ApplicationNode;
|
||||
@@ -282,7 +281,6 @@ export const addJavaScriptPackageNodes = (
|
||||
context: JavaScriptArtifactGraphContext,
|
||||
): ApplicationNode[] => {
|
||||
const roots: ApplicationNode[] = [];
|
||||
const rootPath = applicationManifestPath(context);
|
||||
for (const packageValue of context.analysis.packages) {
|
||||
const file = context.filesByPath.get(packageValue.path);
|
||||
if (file === undefined) continue;
|
||||
@@ -311,7 +309,7 @@ export const addJavaScriptPackageNodes = (
|
||||
},
|
||||
],
|
||||
});
|
||||
if (file.path === rootPath) {
|
||||
if (roots.length === 0) {
|
||||
roots.push(node);
|
||||
context.accumulator.addEdge({
|
||||
source_node_id: node.node_id,
|
||||
@@ -332,7 +330,6 @@ export const addJavaScriptPackageNodes = (
|
||||
file,
|
||||
operation: "inventory-package",
|
||||
});
|
||||
if (dependencyManifest(file.path)) continue;
|
||||
addPackageRole(context, {
|
||||
packageNode: node,
|
||||
packageFile: file,
|
||||
@@ -349,24 +346,6 @@ export const addJavaScriptPackageNodes = (
|
||||
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 = (
|
||||
context: JavaScriptArtifactGraphContext,
|
||||
file: JavaScriptArtifactFile,
|
||||
|
||||
@@ -129,50 +129,4 @@ describe("JavaScript recovery workflow", () => {
|
||||
if (cancelled.ok) throw new Error("expected cancellation");
|
||||
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, resolve } from "node:path";
|
||||
import { isAbsolute } from "node:path";
|
||||
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
javascriptRecoveryInputSchema,
|
||||
javascriptRecoveryResultSchema,
|
||||
} from "../../domain/javascript/javascriptRecovery.js";
|
||||
import { jsonObjectSchema, jsonValueSchema } from "../../domain/jsonValue.js";
|
||||
import { jsonObjectSchema } from "../../domain/jsonValue.js";
|
||||
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
||||
@@ -44,38 +44,17 @@ export class JavaScriptRecoveryService {
|
||||
const invalidPaths = (["path", "output_directory"] as const).filter(
|
||||
(field) => !isAbsolute(parsed.data[field]),
|
||||
);
|
||||
const invalidAdditional = (parsed.data.additional_paths ?? []).filter(
|
||||
(additional) => !isAbsolute(additional),
|
||||
);
|
||||
if (invalidPaths.length > 0 || invalidAdditional.length > 0)
|
||||
if (invalidPaths.length > 0)
|
||||
return err(
|
||||
new AnalysisInputError(OPERATION, undefined, [
|
||||
...invalidPaths.map((field) => ({
|
||||
new AnalysisInputError(
|
||||
OPERATION,
|
||||
undefined,
|
||||
invalidPaths.map((field) => ({
|
||||
path: [field],
|
||||
reason: "invalid_format" as const,
|
||||
reason: "invalid_format",
|
||||
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);
|
||||
if (!execution.ok) return execution;
|
||||
@@ -91,11 +70,6 @@ export class JavaScriptRecoveryService {
|
||||
);
|
||||
if (
|
||||
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.sha256 !== result.data.source.sha256 ||
|
||||
execution.value.provider.id !== result.data.engine.id ||
|
||||
@@ -111,7 +85,7 @@ export class JavaScriptRecoveryService {
|
||||
createEvidence(execution.value.subject, execution.value.provider, {
|
||||
operation: OPERATION,
|
||||
parameters: jsonObjectSchema.parse(parsed.data),
|
||||
result: jsonValueSchema.parse(result.data),
|
||||
result: result.data,
|
||||
rawResult: execution.value.rawResult,
|
||||
locations: execution.value.locations,
|
||||
limitations: execution.value.limitations,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { access, stat } from "node:fs/promises";
|
||||
import { access } from "node:fs/promises";
|
||||
import { constants } from "node:fs";
|
||||
import { isAbsolute } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -32,8 +32,6 @@ export class MitmproxyCaptureAdapter implements HistoricalCaptureFormatAdapter {
|
||||
);
|
||||
try {
|
||||
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) {
|
||||
const reason = `Configured mitmdump executable is unavailable: ${command}.`;
|
||||
throw new AnalysisCapabilityUnavailableError(
|
||||
|
||||
@@ -27,12 +27,6 @@ export const registerJavaScriptRecoveryCommands = (
|
||||
outputDirectory: z.string().describe("Absolute absent output directory"),
|
||||
}),
|
||||
options: z.object({
|
||||
additionalPath: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe(
|
||||
"Related absolute UTF-8 JavaScript file; repeat for multiple chunks",
|
||||
),
|
||||
extractionMode: z
|
||||
.enum(["structural", "heuristic", "inspection"])
|
||||
.optional()
|
||||
@@ -57,9 +51,6 @@ export const registerJavaScriptRecoveryCommands = (
|
||||
{
|
||||
path: args.path,
|
||||
output_directory: args.outputDirectory,
|
||||
...(options.additionalPath === undefined
|
||||
? {}
|
||||
: { additional_paths: options.additionalPath }),
|
||||
...(options.extractionMode === undefined
|
||||
? {}
|
||||
: { extraction_mode: options.extractionMode }),
|
||||
|
||||
@@ -13,7 +13,7 @@ export const JAVASCRIPT_RECOVERY_TOOL_CONTRACTS = [
|
||||
...toolContractMetadata("recover_javascript_sources"),
|
||||
kind: "application",
|
||||
description:
|
||||
"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.",
|
||||
"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.",
|
||||
inputSchema: javascriptRecoveryInputSchema,
|
||||
outputSchema: evidenceResultOf(javascriptRecoveryResultSchema),
|
||||
examples: [
|
||||
@@ -24,18 +24,6 @@ export const JAVASCRIPT_RECOVERY_TOOL_CONTRACTS = [
|
||||
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[];
|
||||
|
||||
@@ -3,18 +3,11 @@ import { digestSchema } from "../digests.js";
|
||||
import { analyzeJavaScriptApplicationInputSchema } from "./javascriptApplicationAnalysis.js";
|
||||
import { localPathStringSchema } from "../localPath.js";
|
||||
|
||||
/** Recover derived readable modules from explicitly selected local scripts. */
|
||||
/** Recover derived readable modules from one explicitly selected local script. */
|
||||
export const javascriptRecoveryInputSchema = z.strictObject({
|
||||
path: localPathStringSchema.describe(
|
||||
"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(
|
||||
"Absolute absent output directory; parent must exist",
|
||||
),
|
||||
@@ -43,15 +36,12 @@ const rangeSchema = z.strictObject({
|
||||
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. */
|
||||
export const javascriptRecoveryResultSchema = z.strictObject({
|
||||
source: sourceSchema,
|
||||
additional_sources: z.array(sourceSchema).optional(),
|
||||
source: fileSchema.extend({
|
||||
snapshot_path: z.string().min(1),
|
||||
published_copy: fileSchema,
|
||||
}),
|
||||
engine: z.strictObject({
|
||||
id: z.string().min(1),
|
||||
version: z.string().min(1),
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import { lstat, mkdir, open, opendir, writeFile } from "node:fs/promises";
|
||||
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||
import { lstat, open, opendir, writeFile } from "node:fs/promises";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import { TextDecoder } from "node:util";
|
||||
import { normalizeArtifactPath } from "../../artifacts/ArtifactPaths.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
@@ -90,22 +89,12 @@ export const readRecoveryFile = async (
|
||||
return Buffer.concat(chunks, result.bytes);
|
||||
};
|
||||
|
||||
export type RecoveryInputSnapshot = {
|
||||
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 (
|
||||
/** Snapshot one caller-selected UTF-8 script before invoking the engine. */
|
||||
export const snapshotRecoveryInput = async (
|
||||
path: string,
|
||||
snapshotPath: string,
|
||||
relativePath: string,
|
||||
maximum: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RecoveryInputSnapshot> => {
|
||||
) => {
|
||||
if (!isAbsolute(path))
|
||||
throw recoveryInputFailure(
|
||||
"path",
|
||||
@@ -113,7 +102,7 @@ const snapshotRecoveryInput = async (
|
||||
);
|
||||
let bytes: Buffer;
|
||||
try {
|
||||
bytes = await readRecoveryFile(path, maximum, signal);
|
||||
bytes = await readRecoveryFile(path, RECOVERY_LIMITS.inputBytes, signal);
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof AnalysisCancelledError) throw cause;
|
||||
@@ -123,78 +112,15 @@ const snapshotRecoveryInput = async (
|
||||
cause,
|
||||
);
|
||||
}
|
||||
await mkdir(dirname(snapshotPath), { recursive: true, mode: 0o700 });
|
||||
await writeFile(snapshotPath, bytes, { flag: "wx", mode: 0o600 });
|
||||
return {
|
||||
path,
|
||||
snapshot_path: snapshotPath,
|
||||
relative_path: relativePath,
|
||||
sha256: recoveryDigest(bytes),
|
||||
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. */
|
||||
export const inventoryRecoveryFiles = async (
|
||||
root: string,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import { access, realpath, stat } from "node:fs/promises";
|
||||
import { access, realpath } from "node:fs/promises";
|
||||
import { isAbsolute } from "node:path";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
@@ -87,8 +87,6 @@ export const resolveWakaruCommand = async (
|
||||
if (!isAbsolute(limiter))
|
||||
throw new TypeError("REA_JAVASCRIPT_PRLIMIT_COMMAND must be absolute");
|
||||
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 };
|
||||
} catch (cause: unknown) {
|
||||
const reason = `Configured Wakaru or util-linux prlimit is unavailable: ${recoveryFailureMessage(cause)}`;
|
||||
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
fingerprintRecoveryFile,
|
||||
inventoryRecoveryFiles,
|
||||
readRecoveryFile,
|
||||
type snapshotRecoveryInputs,
|
||||
recoveryDigest,
|
||||
type snapshotRecoveryInput,
|
||||
} from "./RecoveryFiles.js";
|
||||
import {
|
||||
runWakaruCommand,
|
||||
@@ -37,7 +38,7 @@ type RecoveryWorkspace = {
|
||||
root: string;
|
||||
tree: SafeOutputTree;
|
||||
input: JavaScriptRecoveryInput;
|
||||
sources: Awaited<ReturnType<typeof snapshotRecoveryInputs>>;
|
||||
source: Awaited<ReturnType<typeof snapshotRecoveryInput>>;
|
||||
engine: Awaited<ReturnType<typeof resolveWakaruCommand>>;
|
||||
environment: Readonly<Record<string, string | undefined>>;
|
||||
launcher: WakaruLauncher | undefined;
|
||||
@@ -47,7 +48,7 @@ type RecoveryWorkspace = {
|
||||
|
||||
/** Consume the pinned producer representation in a private workspace. */
|
||||
const executeWakaruRecovery = async (context: RecoveryWorkspace) => {
|
||||
const { root, input, sources, engine, environment, launcher, options } =
|
||||
const { root, input, source, engine, environment, launcher, options } =
|
||||
context;
|
||||
const staging = join(root, "modules");
|
||||
const commandContext = {
|
||||
@@ -80,7 +81,7 @@ const executeWakaruRecovery = async (context: RecoveryWorkspace) => {
|
||||
...commandContext,
|
||||
staging,
|
||||
arguments: [
|
||||
...sources.map((source) => source.snapshot_path),
|
||||
source.snapshot_path,
|
||||
`--unpack=${MODES[input.extraction_mode]}`,
|
||||
`--level=${input.rewrite_level}`,
|
||||
"--provenance",
|
||||
@@ -91,20 +92,16 @@ const executeWakaruRecovery = async (context: RecoveryWorkspace) => {
|
||||
],
|
||||
});
|
||||
const files = await inventoryRecoveryFiles(staging, options?.signal);
|
||||
const snapshots = new Map<string, Buffer>();
|
||||
for (const source of sources)
|
||||
snapshots.set(
|
||||
source.snapshot_path,
|
||||
await readRecoveryFile(
|
||||
source.snapshot_path,
|
||||
source.bytes,
|
||||
options?.signal,
|
||||
),
|
||||
);
|
||||
const snapshot = await readRecoveryFile(
|
||||
source.snapshot_path,
|
||||
RECOVERY_LIMITS.inputBytes,
|
||||
options?.signal,
|
||||
);
|
||||
const parsed = await parseWakaruReports({
|
||||
execution: run,
|
||||
provenancePath: join(staging, "provenance.json"),
|
||||
snapshots,
|
||||
snapshotPath: source.snapshot_path,
|
||||
sourceBytes: snapshot,
|
||||
files,
|
||||
});
|
||||
return { version, admitted, run, parsed };
|
||||
@@ -112,8 +109,7 @@ const executeWakaruRecovery = async (context: RecoveryWorkspace) => {
|
||||
|
||||
/** Publish validated recovery and retain exact source and engine bindings. */
|
||||
export const prepareWakaruExecution = async (context: RecoveryWorkspace) => {
|
||||
const { root, tree, input, sources, engine, options } = context;
|
||||
const [source] = sources;
|
||||
const { root, tree, input, source, engine, options } = context;
|
||||
const staging = join(root, "modules");
|
||||
const { version, admitted, run, parsed } =
|
||||
await executeWakaruRecovery(context);
|
||||
@@ -121,25 +117,26 @@ export const prepareWakaruExecution = async (context: RecoveryWorkspace) => {
|
||||
tree,
|
||||
staging,
|
||||
input,
|
||||
sources,
|
||||
source,
|
||||
engine,
|
||||
admitted,
|
||||
parsed,
|
||||
execution: run,
|
||||
...(options?.signal === undefined ? {} : { signal: options.signal }),
|
||||
});
|
||||
for (const selected of sources) {
|
||||
const current = await fingerprintRecoveryFile(
|
||||
selected.path,
|
||||
RECOVERY_LIMITS.inputBytes,
|
||||
options?.signal,
|
||||
if (
|
||||
recoveryDigest(
|
||||
await readRecoveryFile(
|
||||
input.path,
|
||||
RECOVERY_LIMITS.inputBytes,
|
||||
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 (
|
||||
(
|
||||
await fingerprintRecoveryFile(
|
||||
@@ -169,10 +166,7 @@ export const prepareWakaruExecution = async (context: RecoveryWorkspace) => {
|
||||
format: "javascript",
|
||||
},
|
||||
locations: [
|
||||
...sources.map((selected) => ({
|
||||
kind: "artifact-path" as const,
|
||||
path: selected.path,
|
||||
})),
|
||||
{ kind: "artifact-path", path: source.path },
|
||||
...result.modules.map((module) => ({
|
||||
kind: "artifact-path" as const,
|
||||
path: module.artifact.path,
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
checkRecoveryDeadline,
|
||||
recoveryFailureMessage,
|
||||
recoveryInputFailure,
|
||||
snapshotRecoveryInputs,
|
||||
snapshotRecoveryInput,
|
||||
} from "./RecoveryFiles.js";
|
||||
import {
|
||||
resolveWakaruCommand,
|
||||
@@ -55,7 +55,7 @@ export class WakaruProvider implements JavaScriptRecoveryPort {
|
||||
readonly launcher?: WakaruLauncher,
|
||||
) {}
|
||||
|
||||
/** Recover selected scripts using validated reports and verified output bytes. */
|
||||
/** Recover one script using validated upstream reports and verified output bytes. */
|
||||
recover(
|
||||
input: JavaScriptRecoveryInput,
|
||||
options?: ExecutionOptions,
|
||||
@@ -109,9 +109,9 @@ export class WakaruProvider implements JavaScriptRecoveryPort {
|
||||
const staging = join(root, "modules");
|
||||
await mkdir(inputs, { mode: 0o700 });
|
||||
await mkdir(staging, { mode: 0o700 });
|
||||
const sources = await snapshotRecoveryInputs(
|
||||
[input.path, ...(input.additional_paths ?? [])],
|
||||
inputs,
|
||||
const source = await snapshotRecoveryInput(
|
||||
input.path,
|
||||
join(inputs, "bundle.js"),
|
||||
options?.signal,
|
||||
);
|
||||
try {
|
||||
@@ -128,7 +128,7 @@ export class WakaruProvider implements JavaScriptRecoveryPort {
|
||||
root,
|
||||
tree,
|
||||
input,
|
||||
sources,
|
||||
source,
|
||||
engine,
|
||||
environment: this.environment,
|
||||
launcher: this.launcher,
|
||||
|
||||
@@ -7,11 +7,7 @@ import {
|
||||
type JavaScriptRecoveryInput,
|
||||
type JavaScriptRecoveryResult,
|
||||
} from "../../domain/javascript/javascriptRecovery.js";
|
||||
import {
|
||||
readRecoveryFile,
|
||||
recoveryDigest,
|
||||
type RecoveryInputSnapshot,
|
||||
} from "./RecoveryFiles.js";
|
||||
import { readRecoveryFile, recoveryDigest } from "./RecoveryFiles.js";
|
||||
import {
|
||||
recoveryRanges,
|
||||
validateRecoverySourceMap,
|
||||
@@ -32,7 +28,12 @@ type PublicationContext = {
|
||||
tree: SafeOutputTree;
|
||||
staging: string;
|
||||
input: JavaScriptRecoveryInput;
|
||||
sources: readonly [RecoveryInputSnapshot, ...RecoveryInputSnapshot[]];
|
||||
source: {
|
||||
path: string;
|
||||
snapshot_path: string;
|
||||
sha256: string;
|
||||
bytes: number;
|
||||
};
|
||||
engine: Awaited<ReturnType<typeof resolveWakaruCommand>>;
|
||||
admitted: Exclude<WakaruVersionAdmission, { status: "unsupported" }>;
|
||||
parsed: Awaited<ReturnType<typeof parseWakaruReports>>;
|
||||
@@ -46,7 +47,7 @@ type RecoveryWriter = (path: string, bytes: Buffer) => Promise<PublishedFile>;
|
||||
export const publishWakaruArtifacts = async (
|
||||
context: PublicationContext,
|
||||
): Promise<JavaScriptRecoveryResult> => {
|
||||
const { tree, sources, parsed, staging, signal } = context;
|
||||
const { tree, source, parsed, staging, signal } = context;
|
||||
const write = async (path: string, bytes: Buffer) => {
|
||||
const sha256 = recoveryDigest(bytes);
|
||||
const file = await tree.write(
|
||||
@@ -61,25 +62,17 @@ export const publishWakaruArtifacts = async (
|
||||
bytes: file.bytesWritten,
|
||||
};
|
||||
};
|
||||
const publishedSources: JavaScriptRecoveryResult["source"][] = [];
|
||||
for (const selected of sources) {
|
||||
const { relative_path, ...source } = selected;
|
||||
const snapshot = await readRecoveryFile(
|
||||
source.snapshot_path,
|
||||
source.bytes,
|
||||
signal,
|
||||
const snapshot = await readRecoveryFile(
|
||||
source.snapshot_path,
|
||||
RECOVERY_LIMITS.inputBytes,
|
||||
signal,
|
||||
);
|
||||
if (recoveryDigest(snapshot) !== source.sha256)
|
||||
throw new AnalysisOutputError(
|
||||
"recover_javascript_sources",
|
||||
"The input snapshot changed during recovery",
|
||||
);
|
||||
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 publishedCopy = await write("inputs/bundle.js", snapshot);
|
||||
const modules: JavaScriptRecoveryResult["modules"] = [];
|
||||
for (const module of parsed.report.modules) {
|
||||
modules.push(await publishModule(context, module, write));
|
||||
@@ -97,10 +90,7 @@ export const publishWakaruArtifacts = async (
|
||||
),
|
||||
);
|
||||
const details = {
|
||||
source,
|
||||
...(additionalSources.length === 0
|
||||
? {}
|
||||
: { additional_sources: additionalSources }),
|
||||
source: { ...source, published_copy: publishedCopy },
|
||||
engine: {
|
||||
id: "wakaru",
|
||||
version: context.admitted.version,
|
||||
@@ -161,7 +151,7 @@ const publishModule = async (
|
||||
module: PublicationContext["parsed"]["report"]["modules"][number],
|
||||
write: RecoveryWriter,
|
||||
): Promise<JavaScriptRecoveryResult["modules"][number]> => {
|
||||
const { staging, sources, parsed, signal } = context;
|
||||
const { staging, source, parsed, signal } = context;
|
||||
const artifact = await write(
|
||||
`modules/${module.filename}`,
|
||||
await readRecoveryFile(
|
||||
@@ -192,14 +182,6 @@ const publishModule = async (
|
||||
"recover_javascript_sources",
|
||||
`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 {
|
||||
reported_filename: module.filename,
|
||||
reported_status: module.status,
|
||||
@@ -207,8 +189,8 @@ const publishModule = async (
|
||||
source_map: sourceMap,
|
||||
provenance: {
|
||||
reported_input: provenance.input,
|
||||
original_path: origin.path,
|
||||
original_sha256: origin.sha256,
|
||||
original_path: source.path,
|
||||
original_sha256: source.sha256,
|
||||
extraction: provenance.extraction,
|
||||
byte_ranges: recoveryRanges(provenance.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.",
|
||||
"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.",
|
||||
"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.",
|
||||
"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.",
|
||||
] as const;
|
||||
|
||||
@@ -54,7 +54,8 @@ const OPERATION = "recover_javascript_sources";
|
||||
type ReportContext = {
|
||||
execution: Awaited<ReturnType<typeof runWakaruCommand>>;
|
||||
provenancePath: string;
|
||||
snapshots: ReadonlyMap<string, Buffer>;
|
||||
snapshotPath: string;
|
||||
sourceBytes: Buffer;
|
||||
files: readonly { relativePath: string }[];
|
||||
};
|
||||
|
||||
@@ -120,7 +121,7 @@ const validateModuleSet = (
|
||||
provenance: z.output<typeof provenanceSchema>,
|
||||
context: ReportContext,
|
||||
): void => {
|
||||
const { files, snapshots } = context;
|
||||
const { files, snapshotPath, sourceBytes } = context;
|
||||
const registry = new ArtifactPathRegistry();
|
||||
const expected = new Set(["provenance.json"]);
|
||||
const observed = new Set(files.map((file) => file.relativePath));
|
||||
@@ -134,9 +135,7 @@ const validateModuleSet = (
|
||||
expected.add(path);
|
||||
if (observed.has(`${path}.map`)) expected.add(`${path}.map`);
|
||||
const entry = provenance.modules[path];
|
||||
const sourceBytes =
|
||||
entry === undefined ? undefined : snapshots.get(entry.input);
|
||||
if (entry === undefined || sourceBytes === undefined)
|
||||
if (entry === undefined || entry.input !== snapshotPath)
|
||||
throw new TypeError(
|
||||
`Module provenance does not bind the selected input snapshot: ${path}`,
|
||||
);
|
||||
|
||||
@@ -28,11 +28,6 @@ interface Section {
|
||||
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. */
|
||||
export const readPeResourceLayout = (bytes: Buffer) => {
|
||||
requirePeRange(bytes, 0, 64);
|
||||
@@ -69,7 +64,7 @@ export const readPeResourceLayout = (bytes: Buffer) => {
|
||||
offset: bytes.readUInt32LE(at + 20),
|
||||
size: bytes.readUInt32LE(at + 16),
|
||||
};
|
||||
if (section.rva + virtualExtent(section) > 0x100000000)
|
||||
if (section.rva + Math.max(section.virtualSize, section.size) > 0x100000000)
|
||||
peFailure("PE section RVA range overflows.");
|
||||
if (section.size > 0) {
|
||||
requirePeRange(bytes, section.offset, section.size);
|
||||
@@ -95,14 +90,15 @@ export const readPeResourceLayout = (bytes: Buffer) => {
|
||||
if (rva + size > 0x100000000) peFailure("Resource RVA range overflows.");
|
||||
const candidates = sections.filter(
|
||||
(section) =>
|
||||
rva >= section.rva && rva - section.rva < virtualExtent(section),
|
||||
rva >= section.rva &&
|
||||
rva - section.rva < Math.max(section.size, section.virtualSize),
|
||||
);
|
||||
if (rva < headerSize) {
|
||||
if (
|
||||
sections.some(
|
||||
(section) =>
|
||||
section.rva < rva + size &&
|
||||
section.rva + virtualExtent(section) > rva,
|
||||
section.rva + Math.max(section.size, section.virtualSize) > rva,
|
||||
) ||
|
||||
size > headerSize - rva
|
||||
)
|
||||
@@ -115,8 +111,6 @@ export const readPeResourceLayout = (bytes: Buffer) => {
|
||||
const section = candidates[0];
|
||||
if (section === undefined) return peFailure("Missing resource section.");
|
||||
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)
|
||||
peFailure("Resource range is not entirely file-backed in one section.");
|
||||
// A second section starting inside the requested interval is ambiguous too.
|
||||
@@ -125,7 +119,7 @@ export const readPeResourceLayout = (bytes: Buffer) => {
|
||||
(other) =>
|
||||
other !== section &&
|
||||
other.rva < rva + size &&
|
||||
other.rva + virtualExtent(other) > rva,
|
||||
other.rva + Math.max(other.size, other.virtualSize) > rva,
|
||||
)
|
||||
)
|
||||
peFailure("Resource interval intersects overlapping virtual sections.");
|
||||
|
||||
@@ -259,62 +259,3 @@ describe("PE header admission and bounded traversal", () => {
|
||||
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"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,6 +44,11 @@ export class PeResourcesProvider implements PeResourcesPort {
|
||||
}
|
||||
}
|
||||
|
||||
const errorCode = (cause: unknown): unknown =>
|
||||
cause instanceof Error && "code" in cause ? cause.code : undefined;
|
||||
|
||||
// A missing or non-regular caller selection is an input error, as for the
|
||||
// EVM bytecode carrier read; only other I/O remains an artifact fault.
|
||||
const peResourcesFailure = (
|
||||
cause: unknown,
|
||||
input: InspectPeResourcesInput,
|
||||
@@ -51,56 +56,43 @@ const peResourcesFailure = (
|
||||
) => {
|
||||
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}.`,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
const reason = cause instanceof ArtifactReaderFailure ? cause.reason : "io";
|
||||
const message = cause instanceof Error ? cause.message : String(cause);
|
||||
if (reason === "limit")
|
||||
return new AnalysisResourceConstraintError(
|
||||
operation,
|
||||
"memory",
|
||||
message,
|
||||
{
|
||||
max_file_bytes: input.max_file_bytes,
|
||||
max_entries: input.max_entries,
|
||||
},
|
||||
{ cause },
|
||||
);
|
||||
if (reason === "path")
|
||||
return new AnalysisInputError(operation, { cause }, [
|
||||
{ path: ["path"], reason: "invalid_format", message },
|
||||
]);
|
||||
if (reason === "integrity")
|
||||
return new AnalysisArtifactChangedError(operation, input.path, message, {
|
||||
cause,
|
||||
});
|
||||
const code = errorCode(cause);
|
||||
if (code === "ENOENT" || code === "ENOTDIR")
|
||||
return new AnalysisInputError(operation, { cause }, [
|
||||
{
|
||||
path: ["path"],
|
||||
reason: "invalid_value",
|
||||
message: `Selected PE image could not be read (${code}): ${input.path}.`,
|
||||
},
|
||||
]);
|
||||
if (code === "EACCES" || code === "EPERM")
|
||||
return new AnalysisAccessDeniedError(operation, input.path, code, {
|
||||
cause,
|
||||
});
|
||||
return new ArtifactOperationError(
|
||||
operation,
|
||||
cause instanceof ArtifactReaderFailure ? cause.reason : "io",
|
||||
reason,
|
||||
undefined,
|
||||
cause instanceof Error ? cause.message : "PE artifact inspection failed.",
|
||||
{ cause },
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
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,36 +8,7 @@ import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
import { peResourcesSchema } from "../../../src/domain/native/peResources.js";
|
||||
import { peResourceFixture } from "../../../src/native/pe/PeResources.fixture.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.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 });
|
||||
};
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
cliTest(
|
||||
"inspects PE32/PE32+ resources through compiled CLI and real stdio MCP without an active target",
|
||||
@@ -121,20 +92,6 @@ cliTest(
|
||||
).toBe(evidence.evidence_id);
|
||||
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();
|
||||
absent.bytes.fill(0, absent.directoryAt, absent.directoryAt + 8);
|
||||
const path = join(root, "absent.exe");
|
||||
|
||||
@@ -2,10 +2,8 @@ import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js";
|
||||
import {
|
||||
access,
|
||||
chmod,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
rm,
|
||||
symlink,
|
||||
truncate,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
@@ -13,7 +11,6 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { expect, it, onTestFinished } from "vitest";
|
||||
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 {
|
||||
inspectWebNetworkCaptureInputSchema,
|
||||
@@ -23,41 +20,6 @@ import { historicalHar } from "../../fixtures/historicalHar.js";
|
||||
import { decodeHarCapture } from "../../../src/browser/history/HarCapture.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])(
|
||||
"binds decoder replies to the complete selected provider identity (matching name: %s)",
|
||||
async (matching) => {
|
||||
|
||||
@@ -6,19 +6,16 @@ import { peResourceFixture } from "../../../src/native/pe/PeResources.fixture.js
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
describe("PE resource stable filesystem boundary", () => {
|
||||
it("reports non-file and malformed PE selections as caller input errors", async () => {
|
||||
it("rejects symlinks, missing files and directories as caller input without inventing empty inventory", async () => {
|
||||
const root = await createTestTempDirectory("rea-pe-resource-files-");
|
||||
const path = join(root, "image.exe");
|
||||
const alias = join(root, "alias.exe");
|
||||
const malformed = join(root, "malformed.exe");
|
||||
await writeFile(path, peResourceFixture().bytes);
|
||||
await writeFile(malformed, Buffer.from("not a PE image"));
|
||||
await symlink(path, alias);
|
||||
const service = createPeResourcesService();
|
||||
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) {
|
||||
@@ -31,7 +28,7 @@ describe("PE resource stable filesystem boundary", () => {
|
||||
{
|
||||
path: ["path"],
|
||||
reason,
|
||||
message: expect.any(String),
|
||||
message: expect.stringContaining(candidate),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
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, open, readFile, writeFile } from "node:fs/promises";
|
||||
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SafeOutputTree } from "../../../src/artifacts/SafeOutputTree.js";
|
||||
@@ -6,7 +6,6 @@ import { SafeOutputTreeCreationFailure } from "../../../src/artifacts/SafeOutput
|
||||
import { javascriptRecoveryResultSchema } from "../../../src/domain/javascript/javascriptRecovery.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
||||
import { RECOVERY_LIMITS } from "../../../src/javascript/recovery/WakaruRelease.js";
|
||||
import {
|
||||
assertRecoveryCleanup,
|
||||
recoveryFixture,
|
||||
@@ -37,7 +36,6 @@ describe.skipIf(process.platform !== "linux" || process.arch !== "x64")(
|
||||
},
|
||||
});
|
||||
expect(value.source.path).toBe(fixture.path);
|
||||
expect(value.additional_sources).toBeUndefined();
|
||||
expect(value.source.published_copy.sha256).toBe(value.source.sha256);
|
||||
expect(value.modules[0]?.provenance.byte_ranges).toEqual([
|
||||
{ start: 0, end: value.source.bytes },
|
||||
@@ -398,153 +396,3 @@ 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,6 +1,4 @@
|
||||
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 { expect, it, onTestFinished } from "vitest";
|
||||
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
@@ -12,11 +10,9 @@ import {
|
||||
recoveryFixture,
|
||||
} from "../../fixtures/javascript-recovery/provider.js";
|
||||
|
||||
it
|
||||
.skipIf(process.platform !== "linux" || process.arch !== "x64")
|
||||
.each([false, true])(
|
||||
"advertises and executes recovery through the named SDK contract (multiple inputs: %s)",
|
||||
async (multiple) => {
|
||||
it.skipIf(process.platform !== "linux" || process.arch !== "x64")(
|
||||
"advertises and executes recovery through the named SDK contract without a binary target",
|
||||
async () => {
|
||||
const fixture = await recoveryFixture();
|
||||
const session = createTestBinarySession(() => {
|
||||
throw new Error("no binary provider should start");
|
||||
@@ -48,13 +44,7 @@ it
|
||||
const outputSchema: Record<string, unknown> = advertised.outputSchema;
|
||||
expect(ajv.validateSchema(inputSchema)).toBe(true);
|
||||
expect(ajv.validateSchema(outputSchema)).toBe(true);
|
||||
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] } : {}),
|
||||
};
|
||||
const args = { path: fixture.path, output_directory: fixture.output };
|
||||
expect(ajv.validate(inputSchema, args)).toBe(true);
|
||||
const response = await client.callTool({
|
||||
name: "recover_javascript_sources",
|
||||
|
||||
@@ -39,11 +39,3 @@ bounded, and address-bearing; it never compares their text with Hopper. The
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
// 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,71 +14,6 @@ if (args.includes("--version")) {
|
||||
} else {
|
||||
const input = args[0];
|
||||
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);
|
||||
await writeFile(join(dirname(output), "recovery-started"), "started");
|
||||
if (mode === "hang") await new Promise(() => setInterval(() => {}, 1000));
|
||||
|
||||
@@ -38,7 +38,6 @@ export const recoveryFixture = async (
|
||||
...options.env,
|
||||
REA_RECOVERY_FIXTURE_MODE: mode,
|
||||
REA_RECOVERY_ORIGINAL_PATH: path,
|
||||
REA_RECOVERY_ADDITIONAL_PATH: join(root, "chunk.js"),
|
||||
REA_RECOVERY_ENGINE_PATH: command,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user