diff --git a/recaf-core/src/main/java/software/coley/recaf/services/decompile/DecompilerManager.java b/recaf-core/src/main/java/software/coley/recaf/services/decompile/DecompilerManager.java index ab09519d3..83e88e037 100644 --- a/recaf-core/src/main/java/software/coley/recaf/services/decompile/DecompilerManager.java +++ b/recaf-core/src/main/java/software/coley/recaf/services/decompile/DecompilerManager.java @@ -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; @@ -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 bytecodeFilters = new CopyOnWriteArrayList<>(); private final List outputTextFilters = new CopyOnWriteArrayList<>(); @@ -139,8 +143,12 @@ public CompletableFuture 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); @@ -153,14 +161,7 @@ public CompletableFuture 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; @@ -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. */ diff --git a/recaf-core/src/main/java/software/coley/recaf/services/decompile/filter/UnicodeUnescapeOutputTextFilter.java b/recaf-core/src/main/java/software/coley/recaf/services/decompile/filter/UnicodeUnescapeOutputTextFilter.java new file mode 100644 index 000000000..381219bcc --- /dev/null +++ b/recaf-core/src/main/java/software/coley/recaf/services/decompile/filter/UnicodeUnescapeOutputTextFilter.java @@ -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; + }; + } +} diff --git a/recaf-core/src/main/java/software/coley/recaf/util/EscapeUtil.java b/recaf-core/src/main/java/software/coley/recaf/util/EscapeUtil.java index ed681234d..afdfcdadb 100644 --- a/recaf-core/src/main/java/software/coley/recaf/util/EscapeUtil.java +++ b/recaf-core/src/main/java/software/coley/recaf/util/EscapeUtil.java @@ -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. @@ -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)); } /** @@ -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; @@ -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; } diff --git a/recaf-core/src/test/java/software/coley/recaf/services/decompile/DecompilerUnicodeDisplayTest.java b/recaf-core/src/test/java/software/coley/recaf/services/decompile/DecompilerUnicodeDisplayTest.java new file mode 100644 index 000000000..0cd732f18 --- /dev/null +++ b/recaf-core/src/test/java/software/coley/recaf/services/decompile/DecompilerUnicodeDisplayTest.java @@ -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"); + } +} diff --git a/recaf-core/src/test/java/software/coley/recaf/services/decompile/filter/UnicodeUnescapeOutputTextFilterTest.java b/recaf-core/src/test/java/software/coley/recaf/services/decompile/filter/UnicodeUnescapeOutputTextFilterTest.java new file mode 100644 index 000000000..3e09fe80c --- /dev/null +++ b/recaf-core/src/test/java/software/coley/recaf/services/decompile/filter/UnicodeUnescapeOutputTextFilterTest.java @@ -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)); + } +} diff --git a/recaf-core/src/test/java/software/coley/recaf/util/EscapeUtilTest.java b/recaf-core/src/test/java/software/coley/recaf/util/EscapeUtilTest.java index c06c4855f..468eef0e5 100644 --- a/recaf-core/src/test/java/software/coley/recaf/util/EscapeUtilTest.java +++ b/recaf-core/src/test/java/software/coley/recaf/util/EscapeUtilTest.java @@ -36,6 +36,11 @@ void testCasePairs() { ); } + @Test + void unescapeCyrillicUnicodeEscapes() { + assertEquals("Для работы", unescapeUnicode("\\u0414\\u043b\\u044f \\u0440\\u0430\\u0431\\u043e\\u0442\\u044b")); + } + void escapeUnescape(Case... cases) { for (Case c : cases) { // Escaping diff --git a/recaf-ui/src/test/java/software/coley/recaf/services/script/ScriptRunControllerTest.java b/recaf-ui/src/test/java/software/coley/recaf/services/script/ScriptRunControllerTest.java index 2444cb86e..802cb8684 100644 --- a/recaf-ui/src/test/java/software/coley/recaf/services/script/ScriptRunControllerTest.java +++ b/recaf-ui/src/test/java/software/coley/recaf/services/script/ScriptRunControllerTest.java @@ -5,6 +5,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.parallel.Execution; import org.junit.jupiter.api.parallel.ExecutionMode; +import org.junit.jupiter.api.parallel.Isolated; import software.coley.recaf.test.TestBase; import java.nio.file.Path; @@ -17,6 +18,7 @@ /** * Tests for {@link ScriptRunController}. */ +@Isolated @Execution(ExecutionMode.SAME_THREAD) // Tests are not thread safe due to shared static state of the cancellation mechanism. class ScriptRunControllerTest extends TestBase { static ScriptRunController controller; @@ -37,7 +39,7 @@ void testStopAfterRestart() throws Exception { CompletableFuture firstRun = controller.start(key, script); // Wait for the script to start and write to the property a few times. - awaitCounter(counterProperty, 2); + awaitCounter(counterProperty, 2, firstRun); // Verify the script is running and then request it to stop. assertTrue(controller.isRunning(key)); @@ -50,7 +52,7 @@ void testStopAfterRestart() throws Exception { // Start the script again and verify it can run after being stopped. System.clearProperty(counterProperty); CompletableFuture secondRun = controller.start(key, script); - awaitCounter(counterProperty, 2); + awaitCounter(counterProperty, 2, secondRun); assertTrue(controller.isRunning(key)); controller.requestStop(); @@ -71,7 +73,7 @@ void testOnlyOneScriptRunsAtATime() throws Exception { // Start the first script that writes to the given property. System.clearProperty(counterProperty); CompletableFuture firstRun = controller.start(firstKey, script); - awaitCounter(counterProperty, 2); + awaitCounter(counterProperty, 2, firstRun); // Attempt to start a second script while the first is still running. This should fail. CompletableFuture secondRun = controller.start(secondKey, @@ -98,10 +100,21 @@ private static String loopPrintingScript(String counterProperty) { } @SuppressWarnings("all") - private static void awaitCounter(String property, int min) throws InterruptedException { - long end = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(1); - while (Integer.parseInt(System.getProperty(property, "-1")) < min && System.currentTimeMillis() < end) + private static void awaitCounter(@Nonnull String property, int min, @Nonnull CompletableFuture run) throws Exception { + long end = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(5); + while (Integer.parseInt(System.getProperty(property, "-1")) < min && System.currentTimeMillis() < end) { + if (run.isDone()) { + ScriptResult result = run.getNow(null); + if (result != null && !result.wasCancelled()) { + if (result.wasCompileFailure()) + throw new AssertionError("Script compile failed: " + result.getCompileDiagnostics()); + if (result.wasRuntimeError()) + throw new AssertionError("Script runtime error", result.getRuntimeThrowable()); + } + } Thread.sleep(10); - assertTrue(Integer.parseInt(System.getProperty(property, "-1")) >= min); + } + int value = Integer.parseInt(System.getProperty(property, "-1")); + assertTrue(value >= min, () -> "Expected " + property + " >= " + min + " but was " + value); } }