diff --git a/recaf-core/src/main/java/software/coley/recaf/services/deobfuscation/builtin/StaticValueCollectionTransformer.java b/recaf-core/src/main/java/software/coley/recaf/services/deobfuscation/builtin/StaticValueCollectionTransformer.java new file mode 100644 index 000000000..30538b93b --- /dev/null +++ b/recaf-core/src/main/java/software/coley/recaf/services/deobfuscation/builtin/StaticValueCollectionTransformer.java @@ -0,0 +1,323 @@ +package software.coley.recaf.services.deobfuscation.builtin; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import jakarta.enterprise.context.Dependent; +import jakarta.inject.Inject; +import org.objectweb.asm.Handle; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; +import org.objectweb.asm.tree.AbstractInsnNode; +import org.objectweb.asm.tree.ClassNode; +import org.objectweb.asm.tree.FieldInsnNode; +import org.objectweb.asm.tree.LdcInsnNode; +import org.objectweb.asm.tree.MethodInsnNode; +import org.objectweb.asm.tree.MethodNode; +import org.objectweb.asm.tree.analysis.Frame; +import software.coley.recaf.info.JvmClassInfo; +import software.coley.recaf.info.member.FieldMember; +import software.coley.recaf.services.inheritance.InheritanceGraph; +import software.coley.recaf.services.transform.JvmClassTransformer; +import software.coley.recaf.services.transform.JvmTransformerContext; +import software.coley.recaf.services.transform.TransformationException; +import software.coley.recaf.util.analysis.ReAnalyzer; +import software.coley.recaf.util.analysis.ReInterpreter; +import software.coley.recaf.util.analysis.lookup.InvokeVirtualLookup; +import software.coley.recaf.util.analysis.value.DoubleValue; +import software.coley.recaf.util.analysis.value.FloatValue; +import software.coley.recaf.util.analysis.value.IntValue; +import software.coley.recaf.util.analysis.value.LongValue; +import software.coley.recaf.util.analysis.value.ObjectValue; +import software.coley.recaf.util.analysis.value.ReValue; +import software.coley.recaf.workspace.model.Workspace; +import software.coley.recaf.workspace.model.bundle.JvmClassBundle; +import software.coley.recaf.workspace.model.resource.WorkspaceResource; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * A transformer that collects values of {@code static final} field assignments. + * + * @author Matt Coley + */ +@Dependent +public class StaticValueCollectionTransformer implements JvmClassTransformer { + private final Map classValues = new ConcurrentHashMap<>(); + private final Map classFinals = new ConcurrentHashMap<>(); + private final InheritanceGraph graph; + + @Inject + public StaticValueCollectionTransformer(@Nonnull InheritanceGraph graph) { + this.graph = graph; + } + + @Nullable + public ReValue getStaticValue(@Nonnull String className, @Nonnull String fieldName, @Nonnull String fieldDesc) { + StaticValues values = classValues.get(className); + if (values == null) + return null; + return values.get(fieldName, fieldDesc); + } + + @Override + public void transform(@Nonnull JvmTransformerContext context, @Nonnull Workspace workspace, + @Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, + @Nonnull JvmClassInfo classInfo) throws TransformationException { + StaticValues valuesContainer = new StaticValues(); + EffectivelyFinalFields finalContainer = new EffectivelyFinalFields(); + + // TODO: Make some config options for this + // - Option to make unsafe assumptions + // - treat all effectively final candidates as actually final + // - Option to scan other classes for references to our fields to have more thorough 'effective-final' checking + // - will be slower, but it will be opt-in and off by default + + // Populate initial values based on field's default value attribute + for (FieldMember field : classInfo.getFields()) { + if (!field.hasStaticModifier()) + continue; + + // Add to effectively-final container if it is 'static final' + // If the field is private add it to the "maybe" effectively-final list, and we'll confirm it later + if (field.hasFinalModifier()) + finalContainer.add(field.getName(), field.getDescriptor()); + else if (field.hasPrivateModifier()) + // We can only assume private fields are effectively-final if nothing outside the writes to them. + // Any other level of access can be written to by child classes or classes in the same package. + finalContainer.addMaybe(field.getName(), field.getDescriptor()); + + // Skip if there is no default value + Object defaultValue = field.getDefaultValue(); + if (defaultValue == null) + continue; + + // Skip if the value cannot be mapped to our representation + ReValue mappedValue = extractFromAsmConstant(defaultValue); + if (mappedValue == null) + continue; + + // Store the value + valuesContainer.put(field.getName(), field.getDescriptor(), mappedValue); + } + + // Visit of classes and collect static field values of primitives + String className = classInfo.getName(); + if (classInfo.getDeclaredMethod("", "()V") != null) { + ClassNode node = context.getNode(bundle, classInfo); + + // Find the static initializer and determine which fields are "effectively-final" + MethodNode clinit = null; + for (MethodNode method : node.methods) { + if ((method.access & Opcodes.ACC_STATIC) != 0 && method.name.equals("") && method.desc.equals("()V")) { + clinit = method; + } else if (method.instructions != null) { + // Any put-static to a field in our class means it is not effectively-final because the method is not the static initializer + for (AbstractInsnNode instruction : method.instructions) { + if (instruction.getOpcode() == Opcodes.PUTSTATIC && instruction instanceof FieldInsnNode fieldInsn) { + // Skip if not targeting our class + if (!fieldInsn.owner.equals(className)) + continue; + String fieldName = fieldInsn.name; + String fieldDesc = fieldInsn.desc; + finalContainer.removeMaybe(fieldName, fieldDesc); + } + } + } + } + finalContainer.commitMaybeIntoEffectivelyFinals(); + + // Only analyze if we see static setters + if (clinit != null && hasStaticSetters(clinit)) { + ReInterpreter interpreter = new ReInterpreter(graph); + ReAnalyzer analyzer = new ReAnalyzer(interpreter); + try { + Frame[] frames = analyzer.analyze(className, clinit); + AbstractInsnNode[] instructions = clinit.instructions.toArray(); + for (int i = 0; i < instructions.length; i++) { + AbstractInsnNode instruction = instructions[i]; + if (instruction.getOpcode() == Opcodes.PUTSTATIC && instruction instanceof FieldInsnNode fieldInsn) { + // Skip if not targeting our class + if (!fieldInsn.owner.equals(className)) + continue; + + // Skip if the field is not final, or effectively final + String fieldName = fieldInsn.name; + String fieldDesc = fieldInsn.desc; + if (!finalContainer.contains(fieldName, fieldDesc)) + continue; + + // Merge the static value state + Frame frame = frames[i]; + ReValue existingValue = valuesContainer.get(fieldName, fieldDesc); + ReValue stackValue = frame.getStack(frame.getStackSize() - 1); + ReValue merged = existingValue == null ? stackValue : interpreter.merge(existingValue, stackValue); + valuesContainer.put(fieldName, fieldDesc, merged); + } + } + } catch (Throwable t) { + throw new TransformationException("Analysis failure", t); + } + } + } + + // Record the values for the target class if we recorded at least one value + if (!valuesContainer.staticFieldValues.isEmpty()) + classValues.put(className, valuesContainer); + } + + @Nonnull + @Override + public String name() { + return "Static value collection"; + } + + /** + * @param method + * Method to check for {@link Opcodes#PUTSTATIC} use. + * + * @return {@code true} when the method has a {@link Opcodes#PUTSTATIC} instruction. + */ + private static boolean hasStaticSetters(@Nonnull MethodNode method) { + if (method.instructions == null) + return false; + for (AbstractInsnNode abstractInsnNode : method.instructions) + if (abstractInsnNode.getOpcode() == Opcodes.PUTSTATIC) return true; + return false; + } + + /** + * @param value + * ASM constant value. + * + * @return A {@link ReValue} wrapper of the given input, + * or {@code null} if the value could not be represented. + * + * @see LdcInsnNode#cst Possible values + */ + @Nullable + private static ReValue extractFromAsmConstant(Object value) { + if (value instanceof String s) + return ObjectValue.string(s); + if (value instanceof Integer i) + return IntValue.of(i); + if (value instanceof Float f) + return FloatValue.of(f); + if (value instanceof Long l) + return LongValue.of(l); + if (value instanceof Double d) + return DoubleValue.of(d); + if (value instanceof Type type) { + if (type.getSort() == Type.METHOD) + return ObjectValue.VAL_METHOD_TYPE; + else + return ObjectValue.VAL_CLASS; + } + if (value instanceof Handle handle) + return ObjectValue.VAL_METHOD_HANDLE; + return null; + } + + /** + * Wrapper/utility for field finality storage/lookups. + */ + private static class EffectivelyFinalFields { + private Set finalFieldKeys; + private Set maybeFinalFieldKeys; + + /** + * Add a {@code static final} field. + * + * @param name + * Field name. + * @param desc + * Field descriptor. + */ + public void add(@Nonnull String name, @Nonnull String desc) { + if (finalFieldKeys == null) + finalFieldKeys = new HashSet<>(); + finalFieldKeys.add(key(name, desc)); + } + + /** + * Add a {@code static} field that may be effectively final. + * + * @param name + * Field name. + * @param desc + * Field descriptor. + */ + public void addMaybe(@Nonnull String name, @Nonnull String desc) { + if (maybeFinalFieldKeys == null) + maybeFinalFieldKeys = new HashSet<>(); + maybeFinalFieldKeys.add(key(name, desc)); + } + + /** + * Remove a field from being considered possibly effectively final. + * + * @param name + * Field name. + * @param desc + * Field descriptor. + */ + public void removeMaybe(@Nonnull String name, @Nonnull String desc) { + if (maybeFinalFieldKeys != null) + maybeFinalFieldKeys.remove(key(name, desc)); + } + + /** + * Commit all possible effectively final fields into the final fields set. + */ + public void commitMaybeIntoEffectivelyFinals() { + if (maybeFinalFieldKeys != null) + if (finalFieldKeys == null) + finalFieldKeys = new HashSet<>(maybeFinalFieldKeys); + else + finalFieldKeys.addAll(maybeFinalFieldKeys); + } + + /** + * @param name + * Field name. + * @param desc + * Field descriptor. + * + * @return {@code true} when the field is {@code final} or effectively {@code final}. + */ + public boolean contains(@Nonnull String name, @Nonnull String desc) { + if (finalFieldKeys == null) + return false; + return finalFieldKeys.contains(key(name, desc)); + } + + @Nonnull + private static String key(@Nonnull String name, @Nonnull String desc) { + return name + " " + desc; + } + } + + /** + * Wrapper/utility for field value storage/lookups. + */ + private static class StaticValues { + private final Map staticFieldValues = new ConcurrentHashMap<>(); + + private void put(@Nonnull String name, @Nonnull String desc, @Nonnull ReValue value) { + staticFieldValues.put(getKey(name, desc), value); + } + + @Nullable + private ReValue get(@Nonnull String name, @Nonnull String desc) { + return staticFieldValues.get(getKey(name, desc)); + } + + @Nonnull + private static String getKey(@Nonnull String name, @Nonnull String desc) { + return name + ' ' + desc; + } + } +} diff --git a/recaf-core/src/main/java/software/coley/recaf/services/deobfuscation/builtin/StaticValueInliningTransformer.java b/recaf-core/src/main/java/software/coley/recaf/services/deobfuscation/builtin/StaticValueInliningTransformer.java new file mode 100644 index 000000000..528d3fb83 --- /dev/null +++ b/recaf-core/src/main/java/software/coley/recaf/services/deobfuscation/builtin/StaticValueInliningTransformer.java @@ -0,0 +1,110 @@ +package software.coley.recaf.services.deobfuscation.builtin; + +import jakarta.annotation.Nonnull; +import jakarta.enterprise.context.Dependent; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.tree.AbstractInsnNode; +import org.objectweb.asm.tree.ClassNode; +import org.objectweb.asm.tree.FieldInsnNode; +import org.objectweb.asm.tree.InsnNode; +import org.objectweb.asm.tree.LdcInsnNode; +import org.objectweb.asm.tree.MethodNode; +import software.coley.recaf.info.JvmClassInfo; +import software.coley.recaf.services.transform.JvmClassTransformer; +import software.coley.recaf.services.transform.JvmTransformerContext; +import software.coley.recaf.services.transform.TransformationException; +import software.coley.recaf.util.AsmInsnUtil; +import software.coley.recaf.util.analysis.value.DoubleValue; +import software.coley.recaf.util.analysis.value.FloatValue; +import software.coley.recaf.util.analysis.value.IntValue; +import software.coley.recaf.util.analysis.value.LongValue; +import software.coley.recaf.util.analysis.value.ObjectValue; +import software.coley.recaf.util.analysis.value.ReValue; +import software.coley.recaf.util.analysis.value.StringValue; +import software.coley.recaf.workspace.model.Workspace; +import software.coley.recaf.workspace.model.bundle.JvmClassBundle; +import software.coley.recaf.workspace.model.resource.WorkspaceResource; + +import java.util.Collections; +import java.util.Set; + +/** + * A transformer that inlines values from {@link StaticValueCollectionTransformer}. + * + * @author Matt Coley + */ +@Dependent +public class StaticValueInliningTransformer implements JvmClassTransformer { + @Override + @SuppressWarnings("OptionalGetWithoutIsPresent") + public void transform(@Nonnull JvmTransformerContext context, @Nonnull Workspace workspace, + @Nonnull WorkspaceResource resource, @Nonnull JvmClassBundle bundle, + @Nonnull JvmClassInfo classInfo) throws TransformationException { + var staticValueCollector = context.getJvmTransformer(StaticValueCollectionTransformer.class); + + boolean dirty = false; + ClassNode node = context.getNode(bundle, classInfo); + for (MethodNode method : node.methods) { + // Skip static initializer and abstract methods + if (method.name.contains("") || method.instructions == null) + continue; + + for (AbstractInsnNode instruction : method.instructions) { + if (instruction instanceof FieldInsnNode fieldInsn) { + // Get the known value of the static field + ReValue value = staticValueCollector.getStaticValue(fieldInsn.owner, fieldInsn.name, fieldInsn.desc); + if (value == null) + continue; + + // Replace static get calls with their known values + if (value.hasKnownValue()) { + switch (value) { + case IntValue intValue -> { + method.instructions.set(instruction, AsmInsnUtil.intToInsn(intValue.value().getAsInt())); + dirty = true; + } + case LongValue longValue -> { + method.instructions.set(instruction, AsmInsnUtil.longToInsn(longValue.value().getAsLong())); + dirty = true; + } + case DoubleValue doubleValue -> { + method.instructions.set(instruction, AsmInsnUtil.doubleToInsn(doubleValue.value().getAsDouble())); + dirty = true; + } + case FloatValue floatValue -> { + method.instructions.set(instruction, AsmInsnUtil.floatToInsn((float) floatValue.value().getAsDouble())); + dirty = true; + } + case StringValue stringValue -> { + method.instructions.set(instruction, new LdcInsnNode(stringValue.getText().get())); + dirty = true; + } + default -> { + // no-op + } + } + } else if (value == ObjectValue.VAL_OBJECT_NULL) { + method.instructions.set(instruction, new InsnNode(Opcodes.ACONST_NULL)); + dirty = true; + } + } + } + } + + // Record transformed class if we made any changes + if (dirty) + context.setNode(bundle, classInfo, node); + } + + @Nonnull + @Override + public String name() { + return "Static value inlining"; + } + + @Nonnull + @Override + public Set> dependencies() { + return Collections.singleton(StaticValueCollectionTransformer.class); + } +} diff --git a/recaf-core/src/main/java/software/coley/recaf/util/AsmInsnUtil.java b/recaf-core/src/main/java/software/coley/recaf/util/AsmInsnUtil.java new file mode 100644 index 000000000..d22d3c58a --- /dev/null +++ b/recaf-core/src/main/java/software/coley/recaf/util/AsmInsnUtil.java @@ -0,0 +1,139 @@ +package software.coley.recaf.util; + +import jakarta.annotation.Nonnull; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; +import org.objectweb.asm.tree.AbstractInsnNode; +import org.objectweb.asm.tree.InsnNode; +import org.objectweb.asm.tree.IntInsnNode; +import org.objectweb.asm.tree.LdcInsnNode; + +/** + * ASM instruction utilities. + * + * @author Matt Coley + */ +public class AsmInsnUtil implements Opcodes { + /** + * @param type + * Type to push. + * + * @return Instruction to push a default value of the given type onto the stack. + */ + @Nonnull + public static AbstractInsnNode getDefaultValue(@Nonnull Type type) { + return switch (type.getSort()) { + case Type.BOOLEAN, Type.CHAR, Type.BYTE, Type.SHORT, Type.INT -> new InsnNode(ICONST_0); + case Type.LONG -> new InsnNode(LCONST_0); + case Type.FLOAT -> new InsnNode(FCONST_0); + case Type.DOUBLE -> new InsnNode(DCONST_0); + default -> new InsnNode(ACONST_NULL); + }; + } + + /** + * Create an instruction to hold a given {@code int} value. + * + * @param value + * Value to hold. + * + * @return Insn with const value. + */ + @Nonnull + public static AbstractInsnNode intToInsn(int value) { + switch (value) { + case -1: + return new InsnNode(ICONST_M1); + case 0: + return new InsnNode(ICONST_0); + case 1: + return new InsnNode(ICONST_1); + case 2: + return new InsnNode(ICONST_2); + case 3: + return new InsnNode(ICONST_3); + case 4: + return new InsnNode(ICONST_4); + case 5: + return new InsnNode(ICONST_5); + default: + if (value >= Byte.MIN_VALUE && value <= Byte.MAX_VALUE) { + return new IntInsnNode(BIPUSH, value); + } else if (value >= Short.MIN_VALUE && value <= Short.MAX_VALUE) { + return new IntInsnNode(SIPUSH, value); + } else { + return new LdcInsnNode(value); + } + } + } + + /** + * Create an instruction to hold a given {@code float} value. + * + * @param value + * Value to hold. + * + * @return Insn with const value. + */ + @Nonnull + public static AbstractInsnNode floatToInsn(float value) { + if (value == 0) + return new InsnNode(FCONST_0); + if (value == 1) + return new InsnNode(FCONST_1); + if (value == 2) + return new InsnNode(FCONST_2); + return new LdcInsnNode(value); + } + + /** + * Create an instruction to hold a given {@code double} value. + * + * @param value + * Value to hold. + * + * @return Insn with const value. + */ + @Nonnull + public static AbstractInsnNode doubleToInsn(double value) { + if (value == 0) + return new InsnNode(DCONST_0); + if (value == 1) + return new InsnNode(DCONST_1); + return new LdcInsnNode(value); + } + + /** + * Create an instruction to hold a given {@code long} value. + * + * @param value + * Value to hold. + * + * @return Insn with const value. + */ + @Nonnull + public static AbstractInsnNode longToInsn(long value) { + if (value == 0) + return new InsnNode(LCONST_0); + if (value == 1) + return new InsnNode(LCONST_1); + return new LdcInsnNode(value); + } + + /** + * @param type + * Some type. + * + * @return Method return instruction opcode for the given type. + */ + public static int getReturnOpcode(@Nonnull Type type) { + return switch (type.getSort()) { + case Type.VOID -> RETURN; + case Type.BOOLEAN, Type.CHAR, Type.BYTE, Type.SHORT, Type.INT -> IRETURN; + case Type.FLOAT -> FRETURN; + case Type.LONG -> LRETURN; + case Type.DOUBLE -> DRETURN; + default -> ARETURN; + }; + } +} diff --git a/recaf-core/src/main/java/software/coley/recaf/util/analysis/Nullness.java b/recaf-core/src/main/java/software/coley/recaf/util/analysis/Nullness.java index 55c7190d3..8df05ce2f 100644 --- a/recaf-core/src/main/java/software/coley/recaf/util/analysis/Nullness.java +++ b/recaf-core/src/main/java/software/coley/recaf/util/analysis/Nullness.java @@ -1,10 +1,24 @@ package software.coley.recaf.util.analysis; +import jakarta.annotation.Nonnull; + /** * Nullability state. * * @author Matt Coley */ public enum Nullness { - NULL, NOT_NULL, UNKNOWN + UNKNOWN, NULL, NOT_NULL; + + /** + * @param other + * Other value to merge with. + * + * @return Common nullability state. + */ + @Nonnull + public Nullness mergeWith(@Nonnull Nullness other) { + if (this != other) return UNKNOWN; + return this; + } } diff --git a/recaf-core/src/main/java/software/coley/recaf/util/analysis/ReInterpreter.java b/recaf-core/src/main/java/software/coley/recaf/util/analysis/ReInterpreter.java index 403608dfa..83bb82737 100644 --- a/recaf-core/src/main/java/software/coley/recaf/util/analysis/ReInterpreter.java +++ b/recaf-core/src/main/java/software/coley/recaf/util/analysis/ReInterpreter.java @@ -18,10 +18,16 @@ import org.objectweb.asm.tree.analysis.AnalyzerException; import org.objectweb.asm.tree.analysis.Interpreter; import org.objectweb.asm.tree.analysis.SimpleVerifier; +import org.slf4j.Logger; import software.coley.recaf.RecafConstants; +import software.coley.recaf.analytics.logging.Logging; import software.coley.recaf.services.inheritance.InheritanceGraph; import software.coley.recaf.services.inheritance.InheritanceVertex; import software.coley.recaf.util.Types; +import software.coley.recaf.util.analysis.lookup.GetFieldLookup; +import software.coley.recaf.util.analysis.lookup.GetStaticLookup; +import software.coley.recaf.util.analysis.lookup.InvokeStaticLookup; +import software.coley.recaf.util.analysis.lookup.InvokeVirtualLookup; import software.coley.recaf.util.analysis.value.ArrayValue; import software.coley.recaf.util.analysis.value.DoubleValue; import software.coley.recaf.util.analysis.value.FloatValue; @@ -30,8 +36,6 @@ import software.coley.recaf.util.analysis.value.ObjectValue; import software.coley.recaf.util.analysis.value.ReValue; import software.coley.recaf.util.analysis.value.UninitializedValue; -import software.coley.recaf.util.analysis.value.impl.ArrayValueImpl; -import software.coley.recaf.util.analysis.value.impl.ObjectValueImpl; import java.util.List; import java.util.OptionalInt; @@ -43,13 +47,34 @@ * @see ReValue Base enhanced value type. */ public class ReInterpreter extends Interpreter implements Opcodes { + private static final Logger logger = Logging.get(ReInterpreter.class); private final InheritanceGraph graph; + private GetStaticLookup getStaticLookup; + private GetFieldLookup getFieldLookup; + private InvokeStaticLookup invokeStaticLookup; + private InvokeVirtualLookup invokeVirtualLookup; public ReInterpreter(@Nonnull InheritanceGraph graph) { super(RecafConstants.getAsmVersion()); this.graph = graph; } + public void setGetStaticLookup(@Nullable GetStaticLookup getStaticLookup) { + this.getStaticLookup = getStaticLookup; + } + + public void setGetFieldLookup(@Nullable GetFieldLookup getFieldLookup) { + this.getFieldLookup = getFieldLookup; + } + + public void setInvokeStaticLookup(@Nullable InvokeStaticLookup invokeStaticLookup) { + this.invokeStaticLookup = invokeStaticLookup; + } + + public void setInvokeVirtualLookup(@Nullable InvokeVirtualLookup invokeVirtualLookup) { + this.invokeVirtualLookup = invokeVirtualLookup; + } + @Nonnull @SuppressWarnings("DataFlowIssue") // Won't happen because we use arrays private ReValue newArrayValue(@Nonnull Type type, int dimensions) { @@ -69,12 +94,8 @@ public ReValue newValue(@Nullable Type type, @Nonnull Nullness nullness) { case Type.FLOAT -> FloatValue.UNKNOWN; case Type.LONG -> LongValue.UNKNOWN; case Type.DOUBLE -> DoubleValue.UNKNOWN; - case Type.ARRAY -> new ArrayValueImpl(type, nullness); - case Type.OBJECT -> { - if (Types.OBJECT_TYPE.equals(type)) yield ObjectValue.object(nullness); - else if (Types.STRING_TYPE.equals(type)) yield ObjectValue.string(nullness); - yield new ObjectValueImpl(type, nullness); - } + case Type.ARRAY -> ArrayValue.of(type, nullness); + case Type.OBJECT -> ObjectValue.object(type, nullness); default -> throw new IllegalArgumentException("Invalid type for new value: " + type); }; } @@ -161,8 +182,10 @@ public ReValue newOperation(@Nonnull AbstractInsnNode insn) throws AnalyzerExcep case JSR: return ObjectValue.VAL_JSR; case GETSTATIC: - // TODO: Lookup for known static values (Integer.MAX for instance) - Type fieldType = Type.getType(((FieldInsnNode) insn).desc); + FieldInsnNode field = (FieldInsnNode) insn; + if (getStaticLookup != null) + return getStaticLookup.get(field); + Type fieldType = Type.getType(field.desc); return newValue(fieldType); case NEW: Type objectType = Type.getObjectType(((TypeInsnNode) insn).desc); @@ -266,7 +289,10 @@ public ReValue unaryOperation(@Nonnull AbstractInsnNode insn, @Nonnull ReValue v case ATHROW: return null; case GETFIELD: { - Type fieldType = Type.getType(((FieldInsnNode) insn).desc); + FieldInsnNode field = (FieldInsnNode) insn; + if (getFieldLookup != null) + return getFieldLookup.get(field, value); + Type fieldType = Type.getType(field.desc); return newValue(fieldType); } case NEWARRAY: @@ -488,8 +514,12 @@ public ReValue naryOperation(@Nonnull AbstractInsnNode insn, @Nonnull List values); +} diff --git a/recaf-core/src/main/java/software/coley/recaf/util/analysis/lookup/InvokeVirtualLookup.java b/recaf-core/src/main/java/software/coley/recaf/util/analysis/lookup/InvokeVirtualLookup.java new file mode 100644 index 000000000..2cf7dc4de --- /dev/null +++ b/recaf-core/src/main/java/software/coley/recaf/util/analysis/lookup/InvokeVirtualLookup.java @@ -0,0 +1,27 @@ +package software.coley.recaf.util.analysis.lookup; + +import jakarta.annotation.Nonnull; +import org.objectweb.asm.tree.MethodInsnNode; +import software.coley.recaf.util.analysis.value.ReValue; + +import java.util.List; + +/** + * Lookup for context-bound method return values. + * + * @author Matt Coley + */ +public interface InvokeVirtualLookup { + /** + * @param method + * Method reference. + * @param context + * Class context the method resides within. + * @param values + * Argument values to the method. + * + * @return Value representing the return value of the method. + */ + @Nonnull + ReValue get(@Nonnull MethodInsnNode method, @Nonnull ReValue context, @Nonnull List values); +} diff --git a/recaf-core/src/main/java/software/coley/recaf/util/analysis/value/ObjectValue.java b/recaf-core/src/main/java/software/coley/recaf/util/analysis/value/ObjectValue.java index 94d77fd4a..85f366771 100644 --- a/recaf-core/src/main/java/software/coley/recaf/util/analysis/value/ObjectValue.java +++ b/recaf-core/src/main/java/software/coley/recaf/util/analysis/value/ObjectValue.java @@ -45,6 +45,21 @@ static StringValue string(@Nullable String text) { return new StringValueImpl(text); } + /** + * @param nullness + * Null state of the {@link Class}. + * + * @return Object value for a class literal of the given nullness. + */ + @Nonnull + static ObjectValue clazz(@Nonnull Nullness nullness) { + return switch (nullness) { + case NULL -> VAL_CLASS_NULL; + case NOT_NULL -> VAL_CLASS; + case UNKNOWN -> VAL_CLASS_MAYBE_NULL; + }; + } + /** * @param nullness * Null state of the string. @@ -75,6 +90,17 @@ static ObjectValue object(@Nonnull Nullness nullness) { }; } + @Nonnull + static ObjectValue object(@Nonnull Type type, @Nonnull Nullness nullness) { + if (Types.OBJECT_TYPE.equals(type)) + return object(nullness); + if (Types.STRING_TYPE.equals(type)) + return string(nullness); + if (Types.CLASS_TYPE.equals(type)) + return clazz(nullness); + return new ObjectValueImpl(type, nullness); + } + /** * @return Null state of this value. */ diff --git a/recaf-core/src/main/java/software/coley/recaf/util/analysis/value/ReValue.java b/recaf-core/src/main/java/software/coley/recaf/util/analysis/value/ReValue.java index 029fa3b89..ad9dc72f5 100644 --- a/recaf-core/src/main/java/software/coley/recaf/util/analysis/value/ReValue.java +++ b/recaf-core/src/main/java/software/coley/recaf/util/analysis/value/ReValue.java @@ -1,5 +1,6 @@ package software.coley.recaf.util.analysis.value; +import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import org.objectweb.asm.Type; import org.objectweb.asm.tree.analysis.Value; @@ -21,4 +22,13 @@ public sealed interface ReValue extends Value permits IntValue, FloatValue, Doub */ @Nullable Type type(); + + /** + * @param other + * Other value to merge with. + * + * @return Merged value. + */ + @Nonnull + ReValue mergeWith(@Nonnull ReValue other); } diff --git a/recaf-core/src/main/java/software/coley/recaf/util/analysis/value/impl/ArrayValueImpl.java b/recaf-core/src/main/java/software/coley/recaf/util/analysis/value/impl/ArrayValueImpl.java index ad07c714f..931996382 100644 --- a/recaf-core/src/main/java/software/coley/recaf/util/analysis/value/impl/ArrayValueImpl.java +++ b/recaf-core/src/main/java/software/coley/recaf/util/analysis/value/impl/ArrayValueImpl.java @@ -4,6 +4,7 @@ import org.objectweb.asm.Type; import software.coley.recaf.util.analysis.Nullness; import software.coley.recaf.util.analysis.value.ArrayValue; +import software.coley.recaf.util.analysis.value.ReValue; import java.sql.Types; import java.util.OptionalInt; @@ -39,6 +40,21 @@ public Type type() { return type; } + @Nonnull + @Override + public ReValue mergeWith(@Nonnull ReValue other) { + if (other instanceof ArrayValue otherArray) { + if (getFirstDimensionLength().isPresent() && otherArray.getFirstDimensionLength().isPresent()) { + int dim = getFirstDimensionLength().getAsInt(); + int otherDim = otherArray.getFirstDimensionLength().getAsInt(); + if (dim == otherDim) + return ArrayValue.of(type, nullness.mergeWith(otherArray.nullness()), dim); + } + return ArrayValue.of(type, nullness.mergeWith(otherArray.nullness())); + } + throw new IllegalStateException("Cannot merge with: " + other); + } + @Nonnull @Override public Nullness nullness() { diff --git a/recaf-core/src/main/java/software/coley/recaf/util/analysis/value/impl/DoubleValueImpl.java b/recaf-core/src/main/java/software/coley/recaf/util/analysis/value/impl/DoubleValueImpl.java index e2a7ff3ec..14c3e852d 100644 --- a/recaf-core/src/main/java/software/coley/recaf/util/analysis/value/impl/DoubleValueImpl.java +++ b/recaf-core/src/main/java/software/coley/recaf/util/analysis/value/impl/DoubleValueImpl.java @@ -2,6 +2,7 @@ import jakarta.annotation.Nonnull; import software.coley.recaf.util.analysis.value.DoubleValue; +import software.coley.recaf.util.analysis.value.ReValue; import java.util.OptionalDouble; @@ -47,4 +48,19 @@ public int hashCode() { public String toString() { return type().getInternalName() + ":" + (value.isPresent() ? value.getAsDouble() : "?"); } + + @Nonnull + @Override + public ReValue mergeWith(@Nonnull ReValue other) { + if (other instanceof DoubleValue otherDouble) { + if (value().isPresent() && otherDouble.value().isPresent()) { + double d = value().getAsDouble(); + double otherD = otherDouble.value().getAsDouble(); + if (d == otherD) + return DoubleValue.of(d); + } + return DoubleValue.UNKNOWN; + } + throw new IllegalStateException("Cannot merge with: " + other); + } } diff --git a/recaf-core/src/main/java/software/coley/recaf/util/analysis/value/impl/FloatValueImpl.java b/recaf-core/src/main/java/software/coley/recaf/util/analysis/value/impl/FloatValueImpl.java index 7649fd165..e98a7d2f7 100644 --- a/recaf-core/src/main/java/software/coley/recaf/util/analysis/value/impl/FloatValueImpl.java +++ b/recaf-core/src/main/java/software/coley/recaf/util/analysis/value/impl/FloatValueImpl.java @@ -1,7 +1,9 @@ package software.coley.recaf.util.analysis.value.impl; import jakarta.annotation.Nonnull; +import software.coley.recaf.util.analysis.value.DoubleValue; import software.coley.recaf.util.analysis.value.FloatValue; +import software.coley.recaf.util.analysis.value.ReValue; import java.util.OptionalDouble; @@ -51,4 +53,19 @@ public int hashCode() { public String toString() { return type().getInternalName() + ":" + (value.isPresent() ? value.getAsDouble() : "?"); } + + @Nonnull + @Override + public ReValue mergeWith(@Nonnull ReValue other) { + if (other instanceof FloatValue otherFloat) { + if (value().isPresent() && otherFloat.value().isPresent()) { + double f = value().getAsDouble(); + double otherF = otherFloat.value().getAsDouble(); + if (f == otherF) + return FloatValue.of((float) f); + } + return FloatValue.UNKNOWN; + } + throw new IllegalStateException("Cannot merge with: " + other); + } } diff --git a/recaf-core/src/main/java/software/coley/recaf/util/analysis/value/impl/IntValueImpl.java b/recaf-core/src/main/java/software/coley/recaf/util/analysis/value/impl/IntValueImpl.java index 2965e5f14..21f82f630 100644 --- a/recaf-core/src/main/java/software/coley/recaf/util/analysis/value/impl/IntValueImpl.java +++ b/recaf-core/src/main/java/software/coley/recaf/util/analysis/value/impl/IntValueImpl.java @@ -1,7 +1,9 @@ package software.coley.recaf.util.analysis.value.impl; import jakarta.annotation.Nonnull; +import software.coley.recaf.util.analysis.value.FloatValue; import software.coley.recaf.util.analysis.value.IntValue; +import software.coley.recaf.util.analysis.value.ReValue; import java.util.OptionalInt; @@ -47,4 +49,19 @@ public int hashCode() { public String toString() { return type().getInternalName() + ":" + (value.isPresent() ? value.getAsInt() : "?"); } + + @Nonnull + @Override + public ReValue mergeWith(@Nonnull ReValue other) { + if (other instanceof IntValue otherInt) { + if (value().isPresent() && otherInt.value().isPresent()) { + int i = value().getAsInt(); + int otherI = otherInt.value().getAsInt(); + if (i == otherI) + return IntValue.of(i); + } + return IntValue.UNKNOWN; + } + throw new IllegalStateException("Cannot merge with: " + other); + } } diff --git a/recaf-core/src/main/java/software/coley/recaf/util/analysis/value/impl/LongValueImpl.java b/recaf-core/src/main/java/software/coley/recaf/util/analysis/value/impl/LongValueImpl.java index ece109b89..8a192f187 100644 --- a/recaf-core/src/main/java/software/coley/recaf/util/analysis/value/impl/LongValueImpl.java +++ b/recaf-core/src/main/java/software/coley/recaf/util/analysis/value/impl/LongValueImpl.java @@ -1,7 +1,9 @@ package software.coley.recaf.util.analysis.value.impl; import jakarta.annotation.Nonnull; +import software.coley.recaf.util.analysis.value.IntValue; import software.coley.recaf.util.analysis.value.LongValue; +import software.coley.recaf.util.analysis.value.ReValue; import java.util.OptionalLong; @@ -47,4 +49,19 @@ public int hashCode() { public String toString() { return type().getInternalName() + ":" + (value.isPresent() ? value.getAsLong() : "?"); } + + @Nonnull + @Override + public ReValue mergeWith(@Nonnull ReValue other) { + if (other instanceof LongValue otherLong) { + if (value().isPresent() && otherLong.value().isPresent()) { + long i = value().getAsLong(); + long otherI = otherLong.value().getAsLong(); + if (i == otherI) + return LongValue.of(i); + } + return LongValue.UNKNOWN; + } + throw new IllegalStateException("Cannot merge with: " + other); + } } diff --git a/recaf-core/src/main/java/software/coley/recaf/util/analysis/value/impl/ObjectValueImpl.java b/recaf-core/src/main/java/software/coley/recaf/util/analysis/value/impl/ObjectValueImpl.java index 6e0f0d74d..6d06bcc9e 100644 --- a/recaf-core/src/main/java/software/coley/recaf/util/analysis/value/impl/ObjectValueImpl.java +++ b/recaf-core/src/main/java/software/coley/recaf/util/analysis/value/impl/ObjectValueImpl.java @@ -3,7 +3,9 @@ import jakarta.annotation.Nonnull; import org.objectweb.asm.Type; import software.coley.recaf.util.analysis.Nullness; +import software.coley.recaf.util.analysis.value.LongValue; import software.coley.recaf.util.analysis.value.ObjectValue; +import software.coley.recaf.util.analysis.value.ReValue; /** * Object value holder implementation. @@ -30,6 +32,14 @@ public Type type() { return type; } + @Nonnull + @Override + public ReValue mergeWith(@Nonnull ReValue other) { + if (other instanceof ObjectValue otherObject) + return ObjectValue.object(type, nullness().mergeWith(otherObject.nullness())); + throw new IllegalStateException("Cannot merge with: " + other); + } + @Nonnull @Override public Nullness nullness() { diff --git a/recaf-core/src/main/java/software/coley/recaf/util/analysis/value/impl/StringValueImpl.java b/recaf-core/src/main/java/software/coley/recaf/util/analysis/value/impl/StringValueImpl.java index b088ef78c..4514a7c4c 100644 --- a/recaf-core/src/main/java/software/coley/recaf/util/analysis/value/impl/StringValueImpl.java +++ b/recaf-core/src/main/java/software/coley/recaf/util/analysis/value/impl/StringValueImpl.java @@ -4,6 +4,8 @@ import jakarta.annotation.Nullable; import software.coley.recaf.util.Types; import software.coley.recaf.util.analysis.Nullness; +import software.coley.recaf.util.analysis.value.ObjectValue; +import software.coley.recaf.util.analysis.value.ReValue; import software.coley.recaf.util.analysis.value.StringValue; import java.util.Optional; @@ -37,4 +39,20 @@ public Optional getText() { public boolean hasKnownValue() { return nullness() == Nullness.NOT_NULL && text.isPresent(); } + + @Nonnull + @Override + public ReValue mergeWith(@Nonnull ReValue other) { + if (other instanceof StringValue otherString) { + if (getText().isPresent() && otherString.getText().isPresent()) { + String s = getText().get(); + String otherS = otherString.getText().get(); + if (s.equals(otherS)) + return ObjectValue.string(s); + } + } + + // Fall back to object merge logic + return super.mergeWith(other); + } } diff --git a/recaf-core/src/main/java/software/coley/recaf/util/analysis/value/impl/UninitializedValueImpl.java b/recaf-core/src/main/java/software/coley/recaf/util/analysis/value/impl/UninitializedValueImpl.java index 4f38e8e21..0c891cb95 100644 --- a/recaf-core/src/main/java/software/coley/recaf/util/analysis/value/impl/UninitializedValueImpl.java +++ b/recaf-core/src/main/java/software/coley/recaf/util/analysis/value/impl/UninitializedValueImpl.java @@ -1,5 +1,7 @@ package software.coley.recaf.util.analysis.value.impl; +import jakarta.annotation.Nonnull; +import software.coley.recaf.util.analysis.value.ReValue; import software.coley.recaf.util.analysis.value.UninitializedValue; /** @@ -16,4 +18,10 @@ private UninitializedValueImpl() {} public String toString() { return "."; } + + @Nonnull + @Override + public ReValue mergeWith(@Nonnull ReValue other) { + return UNINITIALIZED_VALUE; + } } diff --git a/recaf-core/src/test/java/software/coley/recaf/services/deobfuscation/DeobfuscationTransformTest.java b/recaf-core/src/test/java/software/coley/recaf/services/deobfuscation/DeobfuscationTransformTest.java new file mode 100644 index 000000000..360b06acb --- /dev/null +++ b/recaf-core/src/test/java/software/coley/recaf/services/deobfuscation/DeobfuscationTransformTest.java @@ -0,0 +1,344 @@ +package software.coley.recaf.services.deobfuscation; + +import jakarta.annotation.Nonnull; +import me.darknet.assembler.compile.JavaClassRepresentation; +import me.darknet.assembler.compile.visitor.JavaCompileResult; +import me.darknet.assembler.error.Error; +import me.darknet.assembler.error.Result; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import software.coley.recaf.info.JvmClassInfo; +import software.coley.recaf.info.StubClassInfo; +import software.coley.recaf.info.builder.JvmClassInfoBuilder; +import software.coley.recaf.path.ClassPathNode; +import software.coley.recaf.path.PathNodes; +import software.coley.recaf.services.assembler.JvmAssemblerPipeline; +import software.coley.recaf.services.decompile.DecompileResult; +import software.coley.recaf.services.decompile.JvmDecompiler; +import software.coley.recaf.services.decompile.cfr.CfrConfig; +import software.coley.recaf.services.decompile.cfr.CfrDecompiler; +import software.coley.recaf.services.deobfuscation.builtin.StaticValueInliningTransformer; +import software.coley.recaf.services.transform.TransformResult; +import software.coley.recaf.services.transform.TransformationApplier; +import software.coley.recaf.test.TestBase; +import software.coley.recaf.workspace.model.BasicWorkspace; +import software.coley.recaf.workspace.model.Workspace; +import software.coley.recaf.workspace.model.bundle.JvmClassBundle; +import software.coley.recaf.workspace.model.resource.WorkspaceResource; +import software.coley.recaf.workspace.model.resource.WorkspaceResourceBuilder; + +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for various deobfuscation focused transformers. + */ +class DeobfuscationTransformTest extends TestBase { + private static final boolean PRINT_BEFORE_AFTER = true; + private static final String CLASS_NAME = "Example"; + private static JvmAssemblerPipeline assembler; + private static TransformationApplier transformationApplier; + private static JvmDecompiler decompiler; + private static Workspace workspace; + + @BeforeAll + static void setupServices() { + assembler = recaf.get(JvmAssemblerPipeline.class); + transformationApplier = recaf.get(TransformationApplier.class); + decompiler = new CfrDecompiler(new CfrConfig()); + } + + @BeforeEach + void setupWorkspace() { + workspace = new BasicWorkspace(new WorkspaceResourceBuilder().build()); + workspaceManager.setCurrentIgnoringConditions(workspace); + } + + @Nested + class StaticValueInlining { + @Test + void effectiveFinalAssignmentInClinit() { + String asm = """ + .field private static foo I + + .method public static example ()V { + code: { + A: + getstatic java/lang/System.out Ljava/io/PrintStream; + getstatic Example.foo I + invokevirtual java/io/PrintStream.println (I)V + return + B: + } + } + + .method static ()V { + code: { + A: + iconst_5 + putstatic Example.foo I + return + B: + } + } + """; + validateBeforeAfter(asm, "println(foo);", "println(5);"); + + // With strings + asm = """ + .field private static foo Ljava/lang/String; + + .method public static example ()V { + code: { + A: + getstatic java/lang/System.out Ljava/io/PrintStream; + getstatic Example.foo Ljava/lang/String; + invokevirtual java/io/PrintStream.println (Ljava/lang/String;)V + return + B: + } + } + + .method static ()V { + code: { + A: + ldc "Hello" + putstatic Example.foo Ljava/lang/String; + return + B: + } + } + """; + validateBeforeAfter(asm, "println(foo);", "println(\"Hello\");"); + } + + @Test + void effectiveFinalAssignmentDisqualified() { + String asm = """ + .field private static foo I + + .method public static example ()V { + code: { + A: + getstatic java/lang/System.out Ljava/io/PrintStream; + getstatic Example.foo I + invokevirtual java/io/PrintStream.println (I)V + return + B: + } + } + + .method static disqualification ()V { + code: { + A: + iconst_1 + putstatic Example.foo I + return + B: + } + } + + .method static ()V { + code: { + A: + iconst_5 + putstatic Example.foo I + return + B: + } + } + """; + validateNoTransformation(asm); + } + + @Test + void constAssignmentInClinit() { + String asm = """ + .field private static final foo I + + .method public static example ()V { + code: { + A: + getstatic java/lang/System.out Ljava/io/PrintStream; + getstatic Example.foo I + invokevirtual java/io/PrintStream.println (I)V + return + B: + } + } + + .method static ()V { + code: { + A: + iconst_5 + putstatic Example.foo I + return + B: + } + } + """; + validateBeforeAfter(asm, "println(foo);", "println(5);"); + } + + @Test + void constAssignmentInField() { + String asm = """ + .field private static final foo I { value: 5 } + + .method public static example ()V { + code: { + A: + getstatic java/lang/System.out Ljava/io/PrintStream; + getstatic Example.foo I + invokevirtual java/io/PrintStream.println (I)V + return + B: + } + } + """; + validateBeforeAfter(asm, "println(foo);", "println(5);"); + } + + @Test + void simpleMathComputedAssignment() { + String asm = """ + .field private static final foo I + + .method public static example ()V { + code: { + A: + getstatic java/lang/System.out Ljava/io/PrintStream; + getstatic Example.foo I + invokevirtual java/io/PrintStream.println (I)V + return + B: + } + } + + .method static ()V { + code: { + A: + // 50 * 5 = 250 + bipush 50 + bipush 5 + imul + // 250 / 10 = 25 + bipush 10 + idiv + putstatic Example.foo I + return + B: + } + } + """; + validateBeforeAfter(asm, "println(foo);", "println(25);"); + } + + @Test + @Disabled("Requires array content tracking & tracking value into the creation of a 'new String(T)'") + void stringBase64Decode() { + String asm = """ + .field private static final foo Ljava/lang/String; + + .method public static example ()V { + code: { + A: + getstatic java/lang/System.out Ljava/io/PrintStream; + getstatic Example.foo Ljava/lang/String; + invokevirtual java/io/PrintStream.println (Ljava/lang/String;)V + return + B: + } + } + + .method static ()V { + code: { + A: + new java/lang/String + dup + ldc "SGVsbG8=" + invokestatic java/util/Base64.getDecoder ()Ljava/util/Base64$Decoder; + invokevirtual java/util/Base64$Decoder.decode (Ljava/lang/String;)[B + invokespecial java/lang/String. ([B)V + putstatic Example.foo Ljava/lang/String; + return + B: + } + } + """; + validateBeforeAfter(asm, "println(foo);", "println(\"Hello\");"); + } + } + + private void validateNoTransformation(@Nonnull String assembly) { + JvmClassInfo cls = assemble(assembly); + + // Transforming should not actually result in any changes + TransformResult result = assertDoesNotThrow(() -> transformationApplier.transformJvm(workspace, List.of(StaticValueInliningTransformer.class))); + assertTrue(result.getJvmTransformerFailures().isEmpty(), "There were transformation failures"); + assertEquals(0, result.getJvmTransformedClasses().size(), "There were unexpected transformations applied"); + } + + private void validateBeforeAfter(@Nonnull String assembly, @Nonnull String expectedBefore, @Nonnull String expectedAfter) { + JvmClassInfo cls = assemble(assembly); + + // Before transformation, check that the expected before-state is matched + String initialDecompile = decompile(cls); + if (PRINT_BEFORE_AFTER) System.out.println("======== BEFORE ========\n" + initialDecompile); + assertTrue(initialDecompile.contains(expectedBefore)); + + // Run the transformer + TransformResult result = assertDoesNotThrow(() -> transformationApplier.transformJvm(workspace, List.of(StaticValueInliningTransformer.class))); + assertTrue(result.getJvmTransformerFailures().isEmpty(), "There were transformation failures"); + assertEquals(1, result.getJvmTransformedClasses().size(), "Expected transformation to be applied"); + + // Validate output has been transformed to match the expected after-state. + String transformedDecompile = decompileTransformed(result); + if (PRINT_BEFORE_AFTER) System.out.println("========= AFTER ========\n" + transformedDecompile); + assertTrue(transformedDecompile.contains(expectedAfter)); + } + + @Nonnull + private String decompileTransformed(@Nonnull TransformResult result) { + result.apply(); + JvmClassBundle bundle = workspace.getPrimaryResource().getJvmClassBundle(); + JvmClassInfo cls = bundle.get(CLASS_NAME); + return decompile(cls); + } + + @Nonnull + private String decompile(@Nonnull JvmClassInfo cls) { + DecompileResult result = decompiler.decompile(workspace, cls); + if (result.getText() == null) + fail("Missing decompilation result"); + return result.getText(); + } + + @Nonnull + private JvmClassInfo assemble(@Nonnull String body) { + String assembly = """ + .super java/lang/Object + .class public super %NAME% { + %CODE% + } + """.replace("%NAME%", CLASS_NAME).replace("%CODE%", body); + WorkspaceResource resource = workspace.getPrimaryResource(); + JvmClassBundle bundle = resource.getJvmClassBundle(); + ClassPathNode path = PathNodes.classPath(workspace, resource, bundle, new StubClassInfo(CLASS_NAME).asJvmClass()); + Result result = assembler.tokenize(assembly, "") + .flatMap(assembler::roughParse) + .flatMap(assembler::concreteParse) + .flatMap(concreteAst -> assembler.assemble(concreteAst, path)) + .ifErr(errors -> fail("Errors assembling test input:\n - " + errors.stream().map(Error::toString).collect(Collectors.joining("\n - ")))); + JavaClassRepresentation representation = result.get().representation(); + if (representation == null) fail("No assembler output for test case"); + JvmClassInfo cls = new JvmClassInfoBuilder(representation.classFile()).build(); + bundle.put(cls); + return cls; + } +} \ No newline at end of file