Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@
import software.coley.recaf.info.JvmClassInfo;
import software.coley.recaf.info.properties.builtin.CachedDecompileProperty;
import software.coley.recaf.services.Service;
import software.coley.recaf.services.decompile.cfr.CfrConfig;
import software.coley.recaf.services.decompile.filter.JvmBytecodeFilter;
import software.coley.recaf.services.decompile.filter.OutputTextFilter;
import software.coley.recaf.services.decompile.filter.UnicodeUnescapeOutputTextFilter;
import software.coley.recaf.services.decompile.procyon.ProcyonConfig;
import software.coley.recaf.util.threading.ThreadPoolFactory;
import software.coley.recaf.util.visitors.BogusNameRemovingVisitor;
import software.coley.recaf.util.visitors.ClassHollowingVisitor;
Expand Down Expand Up @@ -52,6 +55,7 @@ public class DecompilerManager implements Service {
private static final NoopJvmDecompiler NO_OP_JVM = NoopJvmDecompiler.getInstance();
private static final NoopAndroidDecompiler NO_OP_ANDROID = NoopAndroidDecompiler.getInstance();
private final JvmBytecodeFilter layeredJvmFilter = new LayeredJvmBytecodeFilter();
private final UnicodeUnescapeOutputTextFilter unicodeUnescapeFilter = new UnicodeUnescapeOutputTextFilter();
private final ExecutorService decompileThreadPool = ThreadPoolFactory.newFixedThreadPool(SERVICE_ID);
private final List<JvmBytecodeFilter> bytecodeFilters = new CopyOnWriteArrayList<>();
private final List<OutputTextFilter> outputTextFilters = new CopyOnWriteArrayList<>();
Expand Down Expand Up @@ -139,8 +143,12 @@ public CompletableFuture<DecompileResult> decompile(@Nonnull JvmDecompiler decom
// and only if the current config matches the one that yielded the cached result.
DecompileResult cachedResult = CachedDecompileProperty.get(classInfo, decompiler);
if (cachedResult != null) {
if (cachedResult.getConfigHash() == decompiler.getConfig().getHash())
return cachedResult;
if (cachedResult.getConfigHash() == decompiler.getConfig().getHash()) {
DecompileResult processed = postProcessOutput(workspace, classInfo, decompiler, cachedResult);
if (doCache && processed != cachedResult)
CachedDecompileProperty.set(classInfo, decompiler, processed);
return processed;
}

// Config changed, void the cache.
CachedDecompileProperty.remove(classInfo);
Expand All @@ -153,14 +161,7 @@ public CompletableFuture<DecompileResult> decompile(@Nonnull JvmDecompiler decom
JvmClassInfo filteredClass = JvmBytecodeFilter.applyFilters(workspace, classInfo, Collections.singletonList(layeredJvmFilter));

// Decompile and cache the results.
DecompileResult result = decompiler.decompile(workspace, filteredClass);
String decompilation = result.getText();
if (decompilation != null && !outputTextFilters.isEmpty()) {
// Apply output filters and re-wrap the result with the new output text.
for (OutputTextFilter textFilter : outputTextFilters)
decompilation = textFilter.filter(workspace, classInfo, decompilation);
result = new DecompileResult(decompilation, result.getConfigHash());
}
DecompileResult result = postProcessOutput(workspace, classInfo, decompiler, decompiler.decompile(workspace, filteredClass));
if (doCache)
CachedDecompileProperty.set(classInfo, decompiler, result);
return result;
Expand Down Expand Up @@ -239,6 +240,37 @@ public void removeOutputTextFilter(@Nonnull OutputTextFilter filter) {
outputTextFilters.remove(filter);
}

@Nonnull
private DecompileResult postProcessOutput(@Nonnull Workspace workspace, @Nonnull JvmClassInfo classInfo,
@Nonnull JvmDecompiler decompiler, @Nonnull DecompileResult result) {
String decompilation = result.getText();
if (decompilation == null)
return result;
String processed = applyOutputTextFilters(workspace, classInfo, decompiler, decompilation);
if (processed.equals(decompilation))
return result;
return new DecompileResult(processed, result.getConfigHash());
}

@Nonnull
private String applyOutputTextFilters(@Nonnull Workspace workspace, @Nonnull JvmClassInfo classInfo,
@Nonnull JvmDecompiler decompiler, @Nonnull String code) {
if (shouldApplyUnicodeUnescape(decompiler))
code = unicodeUnescapeFilter.filter(workspace, classInfo, code);
for (OutputTextFilter textFilter : outputTextFilters)
code = textFilter.filter(workspace, classInfo, code);
return code;
}

private boolean shouldApplyUnicodeUnescape(@Nonnull JvmDecompiler decompiler) {
DecompilerConfig decompilerConfig = decompiler.getConfig();
if (decompilerConfig instanceof CfrConfig cfr)
return cfr.getHideutf().getValue() != CfrConfig.BooleanOption.TRUE;
if (decompilerConfig instanceof ProcyonConfig procyon)
return !procyon.getIsUnicodeOutputEnabled().getValue();
return true;
}

/**
* @return Preferred JVM decompiler.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package software.coley.recaf.services.decompile.filter;

import jakarta.annotation.Nonnull;
import software.coley.recaf.info.ClassInfo;
import software.coley.recaf.util.EscapeUtil;
import software.coley.recaf.workspace.model.Workspace;

public class UnicodeUnescapeOutputTextFilter implements OutputTextFilter {
@Nonnull
@Override
public String filter(@Nonnull Workspace workspace, @Nonnull ClassInfo classInfo, @Nonnull String code) {
return EscapeUtil.unescapeUnicodeIf(code, UnicodeUnescapeOutputTextFilter::isSafeDisplayCodePoint);
}

static boolean isSafeDisplayCodePoint(int codePoint) {
if (codePoint <= 0 || codePoint == EscapeUtil.TERMINATOR || codePoint > Character.MAX_VALUE)
return false;
if (EscapeUtil.isWhitespaceChar((char) codePoint))
return false;
return switch (Character.getType((char) codePoint)) {
case Character.UPPERCASE_LETTER, Character.LOWERCASE_LETTER, Character.TITLECASE_LETTER,
Character.MODIFIER_LETTER, Character.OTHER_LETTER, Character.DECIMAL_DIGIT_NUMBER,
Character.LETTER_NUMBER, Character.OTHER_NUMBER, Character.DASH_PUNCTUATION,
Character.START_PUNCTUATION, Character.END_PUNCTUATION, Character.INITIAL_QUOTE_PUNCTUATION,
Character.FINAL_QUOTE_PUNCTUATION, Character.OTHER_PUNCTUATION, Character.MATH_SYMBOL,
Character.CURRENCY_SYMBOL, Character.MODIFIER_SYMBOL, Character.OTHER_SYMBOL -> true;
default -> false;
};
}
}
24 changes: 22 additions & 2 deletions recaf-core/src/main/java/software/coley/recaf/util/EscapeUtil.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import java.util.BitSet;
import java.util.Set;
import java.util.TreeSet;
import java.util.function.IntPredicate;

/**
* Escape code replacement utility.
Expand Down Expand Up @@ -128,7 +129,19 @@ public static String unescapeStandardAndUnicodeWhitespace(@Nullable String input
* @return String with escaped characters.
*/
public static String unescapeUnicode(@Nullable String input) {
return visit(input, EscapeUtil::computeEscapeUnicode);
return unescapeUnicodeIf(input, codePoint -> true);
}

/**
* @param input
* Input text.
* @param decodeFilter
* Which code points to decode from {@code \\uXXXX} escapes.
*
* @return String with matching unicode escapes decoded.
*/
public static String unescapeUnicodeIf(@Nullable String input, @Nonnull IntPredicate decodeFilter) {
return visit(input, (in, cursor, builder) -> computeEscapeUnicode(in, cursor, builder, decodeFilter));
}

/**
Expand Down Expand Up @@ -251,6 +264,10 @@ private static int computeUnescapeStandard(String input, int cursor, StringBuild
}

private static int computeEscapeUnicode(String input, int cursor, StringBuilder builder) {
return computeEscapeUnicode(input, cursor, builder, codePoint -> true);
}

private static int computeEscapeUnicode(String input, int cursor, StringBuilder builder, IntPredicate decodeFilter) {
// Bounds check
if (cursor + 1 >= input.length()) {
return 0;
Expand Down Expand Up @@ -294,7 +311,10 @@ private static int computeEscapeUnicode(String input, int cursor, StringBuilder
String unicode = input.substring(cursor + len, cursor + len + 4);
try {
int value = Integer.parseInt(unicode, 16);
builder.append(value != TERMINATOR ? (char) value : existing);
if (value == TERMINATOR || !decodeFilter.test(value))
builder.append(existing);
else
builder.append((char) value);
} catch (NumberFormatException ignored) {
return 0;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package software.coley.recaf.services.decompile;

import jakarta.annotation.Nonnull;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.parallel.Execution;
import org.junit.jupiter.api.parallel.ExecutionMode;
import org.objectweb.asm.Opcodes;
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.MethodInsnNode;
import org.objectweb.asm.tree.MethodNode;
import software.coley.recaf.info.JvmClassInfo;
import software.coley.recaf.services.decompile.cfr.CfrConfig;
import software.coley.recaf.services.decompile.cfr.CfrDecompiler;
import software.coley.recaf.services.decompile.procyon.ProcyonConfig;
import software.coley.recaf.services.decompile.procyon.ProcyonDecompiler;
import software.coley.recaf.test.TestBase;
import software.coley.recaf.test.TestClassUtils;
import software.coley.recaf.workspace.model.Workspace;

import java.io.IOException;
import java.util.concurrent.TimeUnit;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

/**
* Regression tests for obfuscation concerns (ZWSP, RTL, whitespace tricks) vs readable Cyrillic.
*/
@Execution(ExecutionMode.SAME_THREAD)
class DecompilerUnicodeDisplayTest extends TestBase {
private static final String CYRILLIC_MESSAGE = "\u0414\u043b\u044f \u0440\u0430\u0431\u043e\u0442\u044b";

private static DecompilerManager decompilerManager;
private static DecompilerManagerConfig managerConfig;
private static Workspace workspace;
private static JvmClassInfo testClass;

@BeforeAll
static void setup() throws IOException {
decompilerManager = recaf.get(DecompilerManager.class);
managerConfig = unwrapProxy(decompilerManager).getServiceConfig();
testClass = TestClassUtils.createClass("UnicodeTestClass", DecompilerUnicodeDisplayTest::appendTestMethods);
workspace = TestClassUtils.fromBundle(TestClassUtils.fromClasses(testClass));
}

@BeforeEach
void setupEach() {
managerConfig.getCacheDecompilations().setValue(false);
workspaceManager.setCurrent(workspace);
}

@AfterEach
void resetConfigs() {
managerConfig.getCacheDecompilations().setValue(true);
setCfrHideUtf(CfrConfig.BooleanOption.DEFAULT);
setProcyonUnicodeOutput(false);
}

@Test
void cfr_showsReadableCyrillic_andPreservesObfuscationEscapes() throws Exception {
String output = decompileWithManager(CfrDecompiler.NAME);
assertReadableCyrillic(output);
assertObfuscationEscapesPreserved(output);
}

@Test
void procyon_showsReadableCyrillic_andPreservesObfuscationEscapes() throws Exception {
String output = decompileWithManager(ProcyonDecompiler.NAME);
assertReadableCyrillic(output);
assertObfuscationEscapesPreserved(output);
}

@Test
void cfr_hideUtfTrue_skipsUnicodeFilter() throws Exception {
setCfrHideUtf(CfrConfig.BooleanOption.TRUE);
assertManagerOutputMatchesRaw(CfrDecompiler.NAME);
}

@Test
void procyon_unicodeOutputEnabled_skipsUnicodeFilter() throws Exception {
setProcyonUnicodeOutput(true);
assertManagerOutputMatchesRaw(ProcyonDecompiler.NAME);
}

private static void setCfrHideUtf(@Nonnull CfrConfig.BooleanOption value) {
((CfrConfig) decompilerManager.getJvmDecompiler(CfrDecompiler.NAME).getConfig()).getHideutf().setValue(value);
}

private static void setProcyonUnicodeOutput(boolean enabled) {
((ProcyonConfig) decompilerManager.getJvmDecompiler(ProcyonDecompiler.NAME).getConfig())
.getIsUnicodeOutputEnabled().setValue(enabled);
}

@Nonnull
private String decompileWithManager(@Nonnull String decompilerName) throws Exception {
JvmDecompiler decompiler = decompilerManager.getJvmDecompiler(decompilerName);
assertNotNull(decompiler);
DecompileResult result = decompilerManager.decompile(decompiler, workspace, testClass)
.get(60, TimeUnit.SECONDS);
String text = result.getText();
assertNotNull(text, () -> "Missing decompilation from " + decompilerName);
return text;
}

private void assertManagerOutputMatchesRaw(@Nonnull String decompilerName) throws Exception {
JvmDecompiler decompiler = decompilerManager.getJvmDecompiler(decompilerName);
assertNotNull(decompiler);
String raw = decompiler.decompile(workspace, testClass).getText();
String managed = decompileWithManager(decompilerName);
assertNotNull(raw);
assertEquals(normalizeLineEndings(raw), normalizeLineEndings(managed),
() -> "Manager output should match raw decompiler when unicode filter is disabled");
}

private static void appendTestMethods(@Nonnull ClassNode node) {
MethodNode method = new MethodNode(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "test", "()V", null, null);
appendPrintln(method, CYRILLIC_MESSAGE);
appendPrintln(method, "\u200b\u200b\u200b");
appendPrintln(method, "before\u202eRTL after");
appendPrintln(method, "\u2004\u2005\uFEFF");
method.instructions.add(new InsnNode(Opcodes.RETURN));
node.methods.add(method);
}

private static void appendPrintln(@Nonnull MethodNode method, @Nonnull String value) {
method.instructions.add(new FieldInsnNode(Opcodes.GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"));
method.instructions.add(new LdcInsnNode(value));
method.instructions.add(new MethodInsnNode(Opcodes.INVOKEVIRTUAL, "java/io/PrintStream", "println",
"(Ljava/lang/String;)V", false));
}

private static void assertReadableCyrillic(@Nonnull String text) {
assertTrue(text.contains(CYRILLIC_MESSAGE) || text.contains("\\u0414"),
() -> "Expected readable Cyrillic. Got:\n" + text);
}

private static void assertObfuscationEscapesPreserved(@Nonnull String text) {
assertFalse(text.contains("\u200b"),
() -> "ZWSP must not appear as a raw invisible character. Got:\n" + text);
assertFalse(text.contains("\u202e"),
() -> "Bidi override must not appear as a raw control character. Got:\n" + text);
assertTrue(text.contains("\\u200b") || text.contains("\\u200B"),
() -> "ZWSP should remain visible as an escape. Got:\n" + text);
assertTrue(text.contains("\\u202e") || text.contains("\\u202E"),
() -> "RTL override should remain visible as an escape. Got:\n" + text);
}

@Nonnull
private static String normalizeLineEndings(@Nonnull String text) {
return text.replace("\r\n", "\n");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package software.coley.recaf.services.decompile.filter;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import software.coley.recaf.info.ClassInfo;
import software.coley.recaf.util.EscapeUtil;
import software.coley.recaf.workspace.model.Workspace;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.mockito.Mockito.mock;

class UnicodeUnescapeOutputTextFilterTest {
private final UnicodeUnescapeOutputTextFilter filter = new UnicodeUnescapeOutputTextFilter();
private final Workspace workspace = mock(Workspace.class);
private final ClassInfo classInfo = mock(ClassInfo.class);

@Test
void decodesCyrillicUnicodeEscapes() {
String escaped = "return \"\\u0414\\u043b\\u044f \\u0440\\u0430\\u0431\\u043e\\u0442\\u044b\";";
String expected = "return \"Для работы\";";
assertEquals(expected, filter.filter(workspace, classInfo, escaped));
}

@ParameterizedTest
@ValueSource(strings = {
"\\u200b", // zero-width space
"\\u200c", // ZWNJ
"\\u200d", // ZWJ
"\\u2028", // line separator
"\\u2029", // paragraph separator
"\\u202a", // LRE
"\\u202b", // RLE
"\\u202c", // PDF
"\\u202d", // LRO
"\\u202e", // RLO / bidi override
"\\u2060", // word joiner
"\\uFEFF", // BOM / ZWNBSP
"\\u1680", // ogham space
"\\u3000", // ideographic space
"\\u2800" // braille blank
})
void leavesObfuscationAndTrickUnicodeEscapes(String escape) {
String input = "name = \"" + escape + "x\";";
assertEquals(input, filter.filter(workspace, classInfo, input));
}

@Test
void blanketUnescapeWouldExposeTricks_selectiveDoesNot() {
String input = "a\\u200bb\\u202ec";
assertNotEquals(input, EscapeUtil.unescapeUnicode(input), "blanket unescape exposes trick characters");
assertEquals(input, filter.filter(workspace, classInfo, input));
}

@Test
void whitespaceOnlyLiteralStaysEscaped() {
String input = "\"\\u200b\\u200b\\u200b\"";
assertEquals(input, filter.filter(workspace, classInfo, input));
}
}
Loading
Loading