From f699ecc841b42a125d25b76ecb5076fcb5c469b4 Mon Sep 17 00:00:00 2001
From: Jona
Date: Thu, 10 Sep 2026 01:48:44 +0200
Subject: [PATCH 1/5] test: pin CustomRemapper public-surface behavior with
consolidation suite
---
.../CustomRemapperConsolidationTest.java | 136 ++++++++++++++++++
1 file changed, 136 insertions(+)
create mode 100644 src/test/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/CustomRemapperConsolidationTest.java
diff --git a/src/test/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/CustomRemapperConsolidationTest.java b/src/test/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/CustomRemapperConsolidationTest.java
new file mode 100644
index 00000000..3b516776
--- /dev/null
+++ b/src/test/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/CustomRemapperConsolidationTest.java
@@ -0,0 +1,136 @@
+package com.javadeobfuscator.deobfuscator.transformers.normalizer;
+
+import org.junit.Test;
+
+import static org.junit.Assert.*;
+
+public class CustomRemapperConsolidationTest {
+
+ private CustomRemapper remapper() {
+ return new CustomRemapper();
+ }
+
+ @Test
+ public void classResolutionWithoutMappingsIsIdentity() {
+ CustomRemapper r = remapper();
+ assertEquals("a/b/C", r.map("a/b/C"));
+ assertEquals("simple", r.map("simple"));
+ }
+
+ @Test
+ public void classMappingStripsMappedPathFromTail() {
+ CustomRemapper r = remapper();
+ assertTrue(r.map("a/b/C", "x/y/Z"));
+ // parent segments come from the package map, not from the mapped value
+ assertEquals("a/b/Z", r.map("a/b/C"));
+ }
+
+ @Test
+ public void packageMappingRewritesOnlyTheTailSegment() {
+ CustomRemapper r = remapper();
+ assertTrue(r.mapPackage("a/b", "x/y"));
+ assertEquals("a/y", r.mapPackage("a/b"));
+ assertEquals("a/y/C", r.map("a/b/C"));
+ }
+
+ @Test
+ public void wholeClassLookupBeatsPackageMapping() {
+ CustomRemapper r = remapper();
+ assertTrue(r.mapPackage("a/b", "x/y"));
+ assertTrue(r.map("a/b/C", "p/Q"));
+ // C's mapped tail is used; the parent still resolves through the package map
+ assertEquals("a/y/Q", r.map("a/b/C"));
+ }
+
+ @Test
+ public void ignorePackagesFlagIsReadPerCallAndDoesNotAffectPackages() {
+ CustomRemapper r = remapper();
+ assertTrue(r.map("a/b/C", "x/y/Z"));
+ assertTrue(r.mapPackage("a/b", "p/q"));
+ r.setIgnorePackages(true);
+ assertEquals("x/y/Z", r.map("a/b/C")); // whole-string lookup, no tail strip
+ assertEquals("a/q", r.mapPackage("a/b")); // package resolution ignores the flag
+ r.setIgnorePackages(false);
+ assertEquals("a/q/Z", r.map("a/b/C"));
+ }
+
+ @Test
+ public void classGuardRejectsTakenTarget() {
+ CustomRemapper r = remapper();
+ assertTrue(r.map("a/b/C", "x/y/Z"));
+ assertFalse(r.map("a/b/D", "x/y/Z"));
+ }
+
+ @Test
+ public void classRetryLoopTerminatesAndStaysConsistent() {
+ // mirrors ClassNormalizer's do/while: failed attempts must not
+ // poison the forward map, or the loop could never terminate
+ CustomRemapper r = remapper();
+ assertTrue(r.map("a/b/C", "pkg/Class0"));
+ String name;
+ int i = 0;
+ do {
+ name = "pkg/Class" + i++;
+ } while (!r.map("a/b/D", name));
+ assertEquals("a/b/Class1", r.map("a/b/D"));
+ }
+
+ @Test
+ public void packageGuardIsBothDirections() {
+ CustomRemapper r = remapper();
+ assertTrue(r.mapPackage("a/b", "x/y"));
+ assertFalse(r.mapPackage("a/b", "p/q")); // old already mapped
+ assertFalse(r.mapPackage("p/r", "x/y")); // target taken
+ }
+
+ @Test
+ public void memberRoundTrip() {
+ CustomRemapper r = remapper();
+ String owner = "a/b/C";
+ assertTrue(r.mapMethodName(owner, "foo", "(I)V", "bar", false));
+ assertEquals("bar", r.mapMethodName(owner, "foo", "(I)V"));
+ assertTrue(r.methodMappingExists(owner, "foo", "(I)V"));
+ assertEquals("foo", r.mapMethodName(owner, "foo", "()V")); // other desc -> unmapped
+ assertTrue(r.mapFieldName(owner, "f", "I", "g", false));
+ assertEquals("g", r.mapFieldName(owner, "f", "I"));
+ assertTrue(r.fieldMappingExists(owner, "f", "I"));
+ }
+
+ @Test
+ public void memberGuardRejectsTakenTargetUnlessForced() {
+ CustomRemapper r = remapper();
+ String owner = "a/b/C";
+ assertTrue(r.mapMethodName(owner, "foo", "(I)V", "bar", false));
+ assertFalse(r.mapMethodName(owner, "baz", "(I)V", "bar", false));
+ assertTrue(r.mapMethodName(owner, "baz", "(I)V", "bar", true));
+ assertEquals("bar", r.mapMethodName(owner, "baz", "(I)V"));
+ }
+
+ @Test
+ public void ownerResolutionIsAppliedBeforeMemberLookup() {
+ CustomRemapper r = remapper();
+ // A mapped package changes only the tail of the resolved owner (a/b -> a/q),
+ // matching the javadoc-pinned mapPackage semantics.
+ assertTrue(r.mapPackage("a/b", "p/q"));
+ assertTrue(r.mapMethodName("a/b/C", "foo", "(I)V", "bar", false)); // stored under map("a/b/C") == "a/q/C"
+ assertEquals("bar", r.mapMethodName("a/q/C", "foo", "(I)V")); // lookup through resolved owner
+ assertEquals("foo", r.mapMethodName("x/y/C", "foo", "(I)V")); // unresolved spelling does not hit
+ }
+
+ @Test
+ public void unmapReturnsOriginal() {
+ CustomRemapper r = remapper();
+ assertTrue(r.map("a/b", "x/y"));
+ assertEquals("a/b", r.unmap("x/y"));
+ assertEquals("missing", r.unmap("missing"));
+ }
+
+ @Test
+ public void siblingOwnerLookupsDoNotLeak() {
+ CustomRemapper r = remapper();
+ assertTrue(r.mapMethodName("a/b/C", "foo", "(I)V", "bar", false));
+ assertTrue(r.mapMethodName("a/b/D", "foo", "(I)V", "baz", false));
+ assertEquals("bar", r.mapMethodName("a/b/C", "foo", "(I)V"));
+ assertEquals("baz", r.mapMethodName("a/b/D", "foo", "(I)V"));
+ }
+}
From 1c1d068380ec76a5780c873f27204a28b373d5eb Mon Sep 17 00:00:00 2001
From: Jona
Date: Thu, 10 Sep 2026 01:53:57 +0200
Subject: [PATCH 2/5] refactor: consolidate custom remapper maps into
TwoWayStringMap and MemberMap
---
.../normalizer/CustomRemapper.java | 274 ++++++++++--------
1 file changed, 158 insertions(+), 116 deletions(-)
diff --git a/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/CustomRemapper.java b/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/CustomRemapper.java
index 520c20b4..805b3f91 100644
--- a/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/CustomRemapper.java
+++ b/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/CustomRemapper.java
@@ -22,63 +22,41 @@
import java.util.Map;
public class CustomRemapper extends Remapper {
- /**
- * If this option is disabled, mapping "package/class" to "newclass" will result in "package/newclass".
- */
- private boolean ignorePackages = false;
-
+ /**
+ * If this option is disabled, mapping "package/class" to "newclass" will result in "package/newclass".
+ */
+ private boolean ignorePackages = false;
+
+ private final TwoWayStringMap packageMap = new TwoWayStringMap(null);
+ private final TwoWayStringMap classMap = new TwoWayStringMap(packageMap);
+ private final MemberStore methodStore = new MemberStore(this);
+ private final MemberStore fieldStore = new MemberStore(this);
+
/**
* Map method name to the new name. Subclasses can override.
*
- * @param owner
- * owner of the method.
- * @param name
- * name of the method.
- * @param desc
- * descriptor of the method.
+ * @param owner owner of the method.
+ * @param name name of the method.
+ * @param desc descriptor of the method.
* @return new name of the method
*/
public String mapMethodName(String owner, String name, String desc) {
- Map map = mapMethod.get(map(owner));
- if (map != null) {
- String data = map.get(name + mapDesc(desc));
- if (data != null) {
- return data;
- }
- }
- return name;
+ return methodStore.read(owner, name, desc);
}
public boolean mapMethodName(String owner, String oldName, String oldDesc, String newName, boolean force) {
- Map methods = mapMethod.get(map(owner));
- Map methodsRev = mapMethodReversed.get(map(owner));
- if (methods == null) {
- methods = new HashMap<>();
- mapMethod.put(map(owner), methods);
- }
- if (methodsRev == null) {
- methodsRev = new HashMap<>();
- mapMethodReversed.put(map(owner), methodsRev);
- }
- if (!methodsRev.containsKey(newName + mapDesc(oldDesc)) || force) {
- methods.put(oldName + mapDesc(oldDesc), newName);
- methodsRev.put(newName + mapDesc(oldDesc), oldName + mapDesc(oldDesc));
- return true;
- }
- return false;
+ return methodStore.write(owner, oldName, oldDesc, newName, force);
}
public boolean methodMappingExists(String owner, String oldName, String oldDesc) {
- return mapMethod.containsKey(map(owner)) && mapMethod.get(map(owner)).containsKey(oldName + mapDesc(oldDesc));
+ return methodStore.exists(owner, oldName, oldDesc);
}
/**
* Map invokedynamic method name to the new name. Subclasses can override.
*
- * @param name
- * name of the invokedynamic.
- * @param desc
- * descriptor of the invokedynamic.
+ * @param name name of the invokedynamic.
+ * @param desc descriptor of the invokedynamic.
* @return new invokdynamic name.
*/
public String mapInvokeDynamicMethodName(String name, String desc) {
@@ -88,113 +66,177 @@ public String mapInvokeDynamicMethodName(String name, String desc) {
/**
* Map field name to the new name. Subclasses can override.
*
- * @param owner
- * owner of the field.
- * @param name
- * name of the field
- * @param desc
- * descriptor of the field
+ * @param owner owner of the field.
+ * @param name name of the field
+ * @param desc descriptor of the field
* @return new name of the field.
*/
public String mapFieldName(String owner, String name, String desc) {
- Map map = mapField.get(map(owner));
- if (map != null) {
- String data = map.get(name + mapDesc(desc));
- if (data != null) {
- return data;
- }
- }
- return name;
+ return fieldStore.read(owner, name, desc);
}
public boolean mapFieldName(String owner, String oldName, String oldDesc, String newName, boolean force) {
- Map fields = mapField.get(map(owner));
- Map fieldsRev = mapFieldReversed.get(map(owner));
- if (fields == null) {
- fields = new HashMap<>();
- mapField.put(map(owner), fields);
- }
- if (fieldsRev == null) {
- fieldsRev = new HashMap<>();
- mapFieldReversed.put(map(owner), fieldsRev);
- }
- if (!fieldsRev.containsKey(newName + mapDesc(oldDesc)) || force) {
- fields.put(oldName + mapDesc(oldDesc), newName);
- fieldsRev.put(newName + mapDesc(oldDesc), oldName + mapDesc(oldDesc));
- return true;
- }
- return false;
+ return fieldStore.write(owner, oldName, oldDesc, newName, force);
}
public boolean fieldMappingExists(String owner, String oldName, String oldDesc) {
- return mapField.containsKey(map(owner)) && mapField.get(map(owner)).containsKey(oldName + mapDesc(oldDesc));
+ return fieldStore.exists(owner, oldName, oldDesc);
}
/**
* Map type name to the new name. Subclasses can override.
*/
public String map(String in) {
- int lin = in.lastIndexOf('/');
- String className = lin == -1 ? in : in.substring(lin + 1);
- if (lin == -1 || ignorePackages) {
- return map.getOrDefault(in, in);
- } else {
- String newClassName = map.getOrDefault(in, className);
- int nlin = newClassName.lastIndexOf('/');
- newClassName = nlin == -1 ? newClassName : newClassName.substring(nlin + 1);
- return mapPackage(in.substring(0, lin)) + "/" + newClassName;
- }
+ return classMap.resolve(in, ignorePackages);
}
public String mapPackage(String in) {
- int lin = in.lastIndexOf('/');
- if (lin != -1) {
- String originalName = in.substring(lin + 1);
- String parentPackage = in.substring(0, lin);
- String newPackageName = packageMap.getOrDefault(in, originalName);
- int nlin = newPackageName.lastIndexOf('/');
- newPackageName = nlin == -1 ? newPackageName : newPackageName.substring(nlin + 1);
- return mapPackage(parentPackage) + "/" + newPackageName;
- } else {
- return packageMap.getOrDefault(in, in);
- }
+ return packageMap.resolve(in, false);
}
public boolean mapPackage(String oldPackage, String newPackage) {
- if (!packageMapReversed.containsKey(newPackage) && !packageMap.containsKey(oldPackage)) {
- packageMapReversed.put(newPackage, oldPackage);
- packageMap.put(oldPackage, newPackage);
+ return packageMap.put(oldPackage, newPackage);
+ }
+
+ public boolean map(String old, String newName) {
+ return classMap.put(old, newName);
+ }
+
+ public String unmap(String ref) {
+ String mapped = classMap.unmap(ref);
+ return mapped == null ? ref : mapped;
+ }
+
+ public void setIgnorePackages(boolean ignorePackages) {
+ this.ignorePackages = ignorePackages;
+ }
+
+ /**
+ * Bidirectional name map for classes and packages. {@code resolve} walks the
+ * name from the deepest segment upward: the map at the current level is
+ * consulted first, ancestor segments are resolved through {@code ancestors}
+ * (the class map's {@code ancestors} is the package map; the package map's
+ * is itself via a {@code null} reference).
+ */
+ private static final class TwoWayStringMap {
+ private final Map forward = new HashMap<>();
+ private final Map reversed = new HashMap<>();
+ private final TwoWayStringMap ancestors;
+
+ TwoWayStringMap(TwoWayStringMap ancestors) {
+ this.ancestors = ancestors;
+ }
+
+ String resolve(String in, boolean ignorePackages) {
+ if (ignorePackages) {
+ return forward.getOrDefault(in, in);
+ }
+ return resolveRecursive(in);
+ }
+
+ private String resolveRecursive(String in) {
+ int lin = in.lastIndexOf('/');
+ if (lin == -1) {
+ return forward.getOrDefault(in, in);
+ }
+ String mapped = forward.getOrDefault(in, in.substring(lin + 1));
+ String clean = mapped.substring(mapped.lastIndexOf('/') + 1);
+ TwoWayStringMap ancestorMap = ancestors != null ? ancestors : this;
+ return ancestorMap.resolveRecursive(in.substring(0, lin)) + "/" + clean;
+ }
+
+ /**
+ * Refused when the target name is already taken (reversed) or the old
+ * name is already mapped (forward). The forward check is behavior-neutral
+ * for classes (each distinct FQN is mapped at most once by every caller)
+ * and load-bearing for packages (repeated hierarchy walks must fail clean).
+ */
+ boolean put(String old, String newName) {
+ if (reversed.containsKey(newName) || forward.containsKey(old)) {
+ return false;
+ }
+ forward.put(old, newName);
+ reversed.put(newName, old);
return true;
}
- return false;
+
+ String unmap(String ref) {
+ return reversed.get(ref);
+ }
}
- private Map map = new HashMap<>();
- private Map mapReversed = new HashMap<>();
+ /**
+ * Per-owner member map. Lookup keys are fused {@code name + desc} for O(1)
+ * access; the structured {@link MemberEntry} value keeps name/desc/to
+ * recoverable without re-parsing (to allow saving them individually later).
+ */
+ private static final class MemberMap {
+ private final Map forward = new HashMap<>();
+ private final Map reversed = new HashMap<>();
+
+ boolean put(String oldName, String oldDesc, String newName, boolean force) {
+ if (reversed.containsKey(newName + oldDesc) && !force) {
+ return false;
+ }
+ MemberEntry entry = new MemberEntry();
+ entry.name = oldName;
+ entry.desc = oldDesc;
+ entry.to = newName;
+ forward.put(oldName + oldDesc, entry);
+ reversed.put(newName + oldDesc, oldName + oldDesc);
+ return true;
+ }
- private Map packageMap = new HashMap<>();
- private Map packageMapReversed = new HashMap<>();
+ String get(String name, String desc) {
+ MemberEntry entry = forward.get(name + desc);
+ return entry == null ? null : entry.to;
+ }
- public boolean map(String old, String newName) {
- if (mapReversed.containsKey(newName)) {
- return false;
+ boolean exists(String name, String desc) {
+ return forward.containsKey(name + desc);
}
- map.put(old, newName);
- mapReversed.put(newName, old);
- return true;
}
- private Map> mapField = new HashMap<>(); //name + desc
- private Map> mapFieldReversed = new HashMap<>(); //name + desc
- private Map> mapMethod = new HashMap<>(); //name + desc
- private Map> mapMethodReversed = new HashMap<>(); //name + desc
+ /**
+ * One per flavor (methods / fields). Owns the per-owner inner maps and the
+ * owner-mapped lookup pattern shared by every member query
+ */
+ private static final class MemberStore {
+ private final CustomRemapper remapper;
+ private final Map owners = new HashMap<>();
+
+ MemberStore(CustomRemapper remapper) {
+ this.remapper = remapper;
+ }
- public String unmap(String ref) {
- return mapReversed.get(ref) == null ? ref : mapReversed.get(ref);
+ String read(String owner, String name, String desc) {
+ MemberMap members = owners.get(remapper.map(owner));
+ if (members == null) {
+ return name;
+ }
+ String data = members.get(name, remapper.mapDesc(desc));
+ return data == null ? name : data;
+ }
+
+ boolean write(String owner, String oldName, String oldDesc, String newName, boolean force) {
+ String mappedOwner = remapper.map(owner);
+ MemberMap members = owners.get(mappedOwner);
+ if (members == null) {
+ members = new MemberMap();
+ owners.put(mappedOwner, members);
+ }
+ return members.put(oldName, remapper.mapDesc(oldDesc), newName, force);
+ }
+
+ boolean exists(String owner, String oldName, String oldDesc) {
+ MemberMap members = owners.get(remapper.map(owner));
+ return members != null && members.exists(oldName, remapper.mapDesc(oldDesc));
+ }
}
-
- public void setIgnorePackages(boolean ignorePackages)
- {
- this.ignorePackages = ignorePackages;
+
+ static final class MemberEntry {
+ String name;
+ String desc;
+ String to;
}
}
From 7655f2051851c3a4ccc1cadf9c26248108fd0bd8 Mon Sep 17 00:00:00 2001
From: Jona
Date: Thu, 10 Sep 2026 02:03:07 +0200
Subject: [PATCH 3/5] mapping: structured MemberEntry save/load via the
consolidated maps
---
.../normalizer/CustomRemapper.java | 94 +++++++++++-
.../normalizer/CustomRemapperMappingTest.java | 139 ++++++++++++++++++
2 files changed, 229 insertions(+), 4 deletions(-)
create mode 100644 src/test/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/CustomRemapperMappingTest.java
diff --git a/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/CustomRemapper.java b/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/CustomRemapper.java
index 805b3f91..1251f317 100644
--- a/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/CustomRemapper.java
+++ b/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/CustomRemapper.java
@@ -18,7 +18,14 @@
import org.objectweb.asm.commons.Remapper;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
import java.util.Map;
public class CustomRemapper extends Remapper {
@@ -111,6 +118,26 @@ public void setIgnorePackages(boolean ignorePackages) {
this.ignorePackages = ignorePackages;
}
+ public void saveMapping(File file) throws IOException {
+ MappingFile mappingFile = new MappingFile();
+ mappingFile.classes = classMap.mappingFileSection();
+ mappingFile.packages = packageMap.mappingFileSection();
+ mappingFile.methods = methodStore.mappingFileSections();
+ mappingFile.fields = fieldStore.mappingFileSections();
+ if (file.getParentFile() != null) {
+ file.getParentFile().mkdirs();
+ }
+ new ObjectMapper().writeValue(file, mappingFile);
+ }
+
+ public void loadMapping(File file) throws IOException {
+ MappingFile root = new ObjectMapper().readValue(file, MappingFile.class);
+ classMap.load(root.classes);
+ packageMap.load(root.packages);
+ methodStore.loadAll(root.methods);
+ fieldStore.loadAll(root.fields);
+ }
+
/**
* Bidirectional name map for classes and packages. {@code resolve} walks the
* name from the deepest segment upward: the map at the current level is
@@ -163,6 +190,23 @@ boolean put(String old, String newName) {
String unmap(String ref) {
return reversed.get(ref);
}
+
+ void load(Map entries) {
+ if (entries == null) {
+ return;
+ }
+ forward.putAll(entries);
+ for (Map.Entry e : entries.entrySet()) {
+ reversed.put(e.getValue(), e.getKey());
+ }
+ }
+
+ Map mappingFileSection() {
+ if (forward.isEmpty()) {
+ return null;
+ }
+ return new LinkedHashMap<>(forward);
+ }
}
/**
@@ -195,6 +239,18 @@ String get(String name, String desc) {
boolean exists(String name, String desc) {
return forward.containsKey(name + desc);
}
+
+ void load(List entries) {
+ for (MemberEntry entry : entries) {
+ String storedKey = entry.name + entry.desc;
+ forward.put(storedKey, entry);
+ reversed.put(entry.to + entry.desc, storedKey);
+ }
+ }
+
+ Collection entries() {
+ return forward.values();
+ }
}
/**
@@ -232,11 +288,41 @@ boolean exists(String owner, String oldName, String oldDesc) {
MemberMap members = owners.get(remapper.map(owner));
return members != null && members.exists(oldName, remapper.mapDesc(oldDesc));
}
+
+ void loadAll(Map> perOwner) {
+ if (perOwner == null) {
+ return;
+ }
+ for (Map.Entry> entry : perOwner.entrySet()) {
+ // replace per owner, exactly like master's loadMapping
+ MemberMap members = new MemberMap();
+ members.load(entry.getValue());
+ owners.put(entry.getKey(), members);
+ }
+ }
+
+ Map> mappingFileSections() {
+ if (owners.isEmpty()) {
+ return null;
+ }
+ Map> out = new LinkedHashMap<>();
+ for (Map.Entry entry : owners.entrySet()) {
+ out.put(entry.getKey(), new ArrayList<>(entry.getValue().entries()));
+ }
+ return out;
+ }
+ }
+
+ public static final class MemberEntry {
+ public String name;
+ public String desc;
+ public String to;
}
- static final class MemberEntry {
- String name;
- String desc;
- String to;
+ public static final class MappingFile {
+ public Map classes;
+ public Map packages;
+ public Map> methods;
+ public Map> fields;
}
}
diff --git a/src/test/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/CustomRemapperMappingTest.java b/src/test/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/CustomRemapperMappingTest.java
new file mode 100644
index 00000000..72765c82
--- /dev/null
+++ b/src/test/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/CustomRemapperMappingTest.java
@@ -0,0 +1,139 @@
+package com.javadeobfuscator.deobfuscator.transformers.normalizer;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+public class CustomRemapperMappingTest {
+ @Rule
+ public TemporaryFolder tempFolder = new TemporaryFolder();
+
+ @Test
+ public void testRoundTrip() throws Exception {
+ CustomRemapper r1 = new CustomRemapper();
+ r1.setIgnorePackages(true);
+ assertTrue(r1.map("old/A", "new/A"));
+ assertTrue(r1.mapPackage("oldpkg", "newpkg"));
+ assertTrue(r1.mapMethodName("old/A", "foo", "()V", "bar", false));
+ assertTrue(r1.mapFieldName("old/A", "f", "I", "g", false));
+
+ File mapping = new File(tempFolder.newFolder(), "mapping.json");
+ r1.saveMapping(mapping);
+
+ CustomRemapper r2 = new CustomRemapper();
+ r2.setIgnorePackages(true);
+ r2.loadMapping(mapping);
+
+ assertEquals("bar", r2.mapMethodName("old/A", "foo", "()V"));
+ assertEquals("g", r2.mapFieldName("old/A", "f", "I"));
+ assertEquals("new/A", r2.map("old/A"));
+ assertEquals("newpkg", r2.mapPackage("oldpkg"));
+ assertEquals("old/A", r2.unmap("new/A"));
+ }
+
+ @Test
+ public void testEmptyMaps() throws Exception {
+ CustomRemapper r1 = new CustomRemapper();
+ File mapping = new File(tempFolder.newFolder(), "empty.json");
+ r1.saveMapping(mapping);
+ assertTrue(mapping.exists());
+
+ CustomRemapper r2 = new CustomRemapper();
+ r2.loadMapping(mapping);
+
+ assertEquals("x", r2.map("x"));
+ assertEquals("m", r2.mapMethodName("A", "m", "()V"));
+ assertEquals("f", r2.mapFieldName("A", "f", "I"));
+ }
+
+ @Test
+ public void testForcePreservesReversed() throws Exception {
+ CustomRemapper r1 = new CustomRemapper();
+ r1.setIgnorePackages(true);
+ assertTrue(r1.map("old/A", "new/A"));
+ assertTrue(r1.mapMethodName("old/A", "foo", "()V", "bar", false));
+
+ File mapping = new File(tempFolder.newFolder(), "method.json");
+ r1.saveMapping(mapping);
+
+ CustomRemapper r2 = new CustomRemapper();
+ r2.setIgnorePackages(true);
+ r2.loadMapping(mapping);
+
+ assertFalse(r2.mapMethodName("old/A", "foo", "()V", "bar", false));
+ }
+
+ private static CustomRemapper.MemberEntry entry(List entries, String name) {
+ for (CustomRemapper.MemberEntry e : entries) {
+ if (e.name.equals(name)) {
+ return e;
+ }
+ }
+ return null;
+ }
+
+ @Test
+ public void testSaveShapeIsStructured() throws Exception {
+ CustomRemapper r1 = new CustomRemapper();
+ r1.setIgnorePackages(true);
+ r1.map("old/A", "new/A");
+ r1.mapMethodName("old/A", "foo", "()V", "Method1", false);
+ r1.mapMethodName("old/A", "bar", "(I)V", "Method2", false);
+ r1.mapFieldName("old/A", "B", "I", "Field1", true);
+
+ File mapping = new File(tempFolder.newFolder(), "mapping.json");
+ r1.saveMapping(mapping);
+
+ CustomRemapper.MappingFile root = new ObjectMapper().readValue(mapping, CustomRemapper.MappingFile.class);
+ assertEquals(Collections.singletonMap("old/A", "new/A"), root.classes);
+ List methods = root.methods.get("new/A");
+ assertEquals(2, methods.size());
+ CustomRemapper.MemberEntry foo = entry(methods, "foo");
+ assertEquals("foo", foo.name);
+ assertEquals("()V", foo.desc);
+ assertEquals("Method1", foo.to);
+ CustomRemapper.MemberEntry bar = entry(methods, "bar");
+ assertEquals("(I)V", bar.desc);
+ assertEquals("Method2", bar.to);
+ List fields = root.fields.get("new/A");
+ assertEquals(1, fields.size());
+ assertEquals("B", fields.get(0).name);
+ assertEquals("I", fields.get(0).desc);
+ assertEquals("Field1", fields.get(0).to);
+ }
+
+ @Test
+ public void testStructuredRoundTrip() throws Exception {
+ CustomRemapper r1 = new CustomRemapper();
+ r1.setIgnorePackages(true);
+ r1.map("old/A", "new/A");
+ r1.mapMethodName("old/A", "foo", "()V", "Method1", false);
+ r1.mapFieldName("old/A", "B", "I", "Field1", true);
+
+ File mapping = new File(tempFolder.newFolder(), "mapping.json");
+ r1.saveMapping(mapping);
+
+ CustomRemapper r2 = new CustomRemapper();
+ r2.setIgnorePackages(true);
+ r2.loadMapping(mapping);
+
+ assertEquals("Method1", r2.mapMethodName("old/A", "foo", "()V"));
+ assertEquals("Field1", r2.mapFieldName("old/A", "B", "I"));
+ assertEquals("new/A", r2.map("old/A"));
+ assertFalse(r2.mapMethodName("old/A", "foo", "()V", "Method1", false));
+ assertFalse(r2.mapFieldName("old/A", "B", "I", "Field1", false));
+ }
+}
\ No newline at end of file
From d9dd48646ad852e9fb23a28f4cb1cf2a9c0eb6a8 Mon Sep 17 00:00:00 2001
From: Jona
Date: Thu, 10 Sep 2026 02:19:43 +0200
Subject: [PATCH 4/5] mapping: wire mapping-file lifecycle into
AbstractNormalizer + lifecycle test
---
pom.xml | 2 +-
.../normalizer/AbstractNormalizer.java | 32 ++-
.../normalizer/ClassNormalizer.java | 6 +-
.../normalizer/CustomRemapper.java | 18 +-
.../normalizer/DuplicateRenamer.java | 33 +--
.../normalizer/PackageNormalizer.java | 17 +-
.../normalizer/SourceFileClassNormalizer.java | 3 +
.../deobfuscator/TestJarBuilder.java | 252 +++++++++++++++++
.../CustomRemapperConsolidationTest.java | 8 -
.../DuplicateClassCollisionTest.java | 258 ++++++++++++++++++
.../normalizer/NormalizerLifecycleTest.java | 218 +++++++++++++++
11 files changed, 807 insertions(+), 40 deletions(-)
create mode 100644 src/test/java/com/javadeobfuscator/deobfuscator/TestJarBuilder.java
create mode 100644 src/test/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/DuplicateClassCollisionTest.java
create mode 100644 src/test/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/NormalizerLifecycleTest.java
diff --git a/pom.xml b/pom.xml
index f4ab5889..c38b5606 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
1.0.0
UTF-8
- 9.5
+ 9.8
1.10.2
diff --git a/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/AbstractNormalizer.java b/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/AbstractNormalizer.java
index 9f905765..7d37f4f7 100644
--- a/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/AbstractNormalizer.java
+++ b/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/AbstractNormalizer.java
@@ -27,6 +27,7 @@
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
+import java.util.concurrent.atomic.AtomicInteger;
@TransformerConfig.ConfigOptions(configClass = AbstractNormalizer.Config.class)
public abstract class AbstractNormalizer extends Transformer {
@@ -34,8 +35,16 @@ public abstract class AbstractNormalizer ex
public final boolean transform() throws Throwable {
CustomRemapper remapper = new CustomRemapper();
+ if (getConfig().getMappingInput() != null && getConfig().getMappingInput().exists()) {
+ remapper.loadMapping(getConfig().getMappingInput());
+ }
+
remap(remapper);
+ if (getConfig().getMappingOutput() != null) {
+ remapper.saveMapping(getConfig().getMappingOutput());
+ }
+
Map updated = new HashMap<>();
Set removed = new HashSet<>();
@@ -61,19 +70,30 @@ public final boolean transform() throws Throwable {
public abstract void remap(CustomRemapper remapper);
public static abstract class Config extends TransformerConfig {
- @JsonProperty(value = "mapping-file")
- private File mappingFile;
+ @JsonProperty(value = "mapping-input")
+ private File mappingInput;
+
+ @JsonProperty(value = "mapping-output")
+ private File mappingOutput;
public Config(Class extends Transformer>> implementation) {
super(implementation);
}
- public File getMappingFile() {
- return mappingFile;
+ public File getMappingInput() {
+ return mappingInput;
+ }
+
+ public void setMappingInput(File mappingInput) {
+ this.mappingInput = mappingInput;
+ }
+
+ public File getMappingOutput() {
+ return mappingOutput;
}
- public void setMappingFile(File mappingFile) {
- this.mappingFile = mappingFile;
+ public void setMappingOutput(File mappingOutput) {
+ this.mappingOutput = mappingOutput;
}
}
}
diff --git a/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/ClassNormalizer.java b/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/ClassNormalizer.java
index d675dd67..459a9412 100644
--- a/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/ClassNormalizer.java
+++ b/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/ClassNormalizer.java
@@ -26,7 +26,11 @@ public class ClassNormalizer extends AbstractNormalizer
public void remap(CustomRemapper remapper) {
AtomicInteger id = new AtomicInteger(0);
classNodes().forEach(classNode -> {
-
+
+ if (remapper.mappingExists(classNode.name)) {
+ return;
+ }
+
String newName = "Class";
if(classNode.name.contains("/")){
diff --git a/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/CustomRemapper.java b/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/CustomRemapper.java
index 1251f317..bea38cfb 100644
--- a/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/CustomRemapper.java
+++ b/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/CustomRemapper.java
@@ -105,6 +105,10 @@ public boolean mapPackage(String oldPackage, String newPackage) {
return packageMap.put(oldPackage, newPackage);
}
+ public boolean packageMappingExists(String old) {
+ return packageMap.contains(old);
+ }
+
public boolean map(String old, String newName) {
return classMap.put(old, newName);
}
@@ -114,6 +118,10 @@ public String unmap(String ref) {
return mapped == null ? ref : mapped;
}
+ public boolean mappingExists(String old) {
+ return classMap.contains(old);
+ }
+
public void setIgnorePackages(boolean ignorePackages) {
this.ignorePackages = ignorePackages;
}
@@ -174,12 +182,10 @@ private String resolveRecursive(String in) {
/**
* Refused when the target name is already taken (reversed) or the old
- * name is already mapped (forward). The forward check is behavior-neutral
- * for classes (each distinct FQN is mapped at most once by every caller)
- * and load-bearing for packages (repeated hierarchy walks must fail clean).
+ * name is already mapped (forward).
*/
boolean put(String old, String newName) {
- if (reversed.containsKey(newName) || forward.containsKey(old)) {
+ if (reversed.containsKey(newName)) {
return false;
}
forward.put(old, newName);
@@ -187,6 +193,10 @@ boolean put(String old, String newName) {
return true;
}
+ boolean contains(String old) {
+ return forward.containsKey(old);
+ }
+
String unmap(String ref) {
return reversed.get(ref);
}
diff --git a/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/DuplicateRenamer.java b/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/DuplicateRenamer.java
index 2f5e38c7..5175b8d8 100644
--- a/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/DuplicateRenamer.java
+++ b/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/DuplicateRenamer.java
@@ -78,17 +78,17 @@ public void remap(CustomRemapper remapper)
});
// Aggressive class name obfuscation
Map names = new HashMap<>();
+ Map toRename = new HashMap<>();
classNodes().forEach(classNode -> {
- String classNodeName = classNode.name;
- if(!names.containsKey(classNodeName.toLowerCase(Locale.ROOT)))
+ String remappedName = remapper.map(classNode.name);
+ if(!names.containsKey(remappedName.toLowerCase(Locale.ROOT)))
{
- names.put(classNodeName.toLowerCase(Locale.ROOT),
- new AtomicInteger());
+ names.put(remappedName.toLowerCase(Locale.ROOT), new AtomicInteger());
if(getConfig().renameIllegalNames())
{
- String rawClassName = classNodeName;
+ String rawClassName = remappedName;
if(classNode.name.contains("/"))
- rawClassName = classNodeName.substring(classNode.name.lastIndexOf('/') + 1);
+ rawClassName = remappedName.substring(classNode.name.lastIndexOf('/') + 1);
boolean illegal = false;
for(String s : ILLEGAL_WINDOWS_CHARACTERS)
if(s.equals(rawClassName.toLowerCase(Locale.ROOT)))
@@ -104,24 +104,21 @@ public void remap(CustomRemapper remapper)
}
if(illegal)
{
- String newName = classNodeName;
- do {
- newName = newName + "_"
- + names.get(classNodeName.toLowerCase(Locale.ROOT))
- .getAndIncrement();
- } while (!remapper.map(classNode.name, newName));
+ toRename.put(classNode.name, names.get(remappedName.toLowerCase(Locale.ROOT)));
}
}
}else
{
- String newName = classNodeName;
- do {
- newName = newName + "_"
- + names.get(classNodeName.toLowerCase(Locale.ROOT))
- .getAndIncrement();
- } while (!remapper.map(classNode.name, newName));
+ toRename.put(classNode.name, names.get(remappedName.toLowerCase(Locale.ROOT)));
}
});
+
+ toRename.forEach((oldName, counter) -> {
+ String newName;
+ do {
+ newName = oldName + "_" + counter.getAndIncrement();;
+ } while (names.containsKey(newName.toLowerCase(Locale.ROOT)) || !remapper.map(oldName, newName));
+ });
List methodNames = new ArrayList<>();
// Aggressive method name obfuscation (same method name + same params)
diff --git a/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/PackageNormalizer.java b/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/PackageNormalizer.java
index 5c068712..475dc7b7 100644
--- a/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/PackageNormalizer.java
+++ b/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/PackageNormalizer.java
@@ -32,16 +32,29 @@ public void remap(CustomRemapper remapper) {
int lin = -1;
while ((lin = packageName.lastIndexOf('/')) != -1) {
String parentPackage = packageName.substring(0, lin);
- if (!remapper.mapPackage(packageName, parentPackage + "/package" + id.getAndIncrement())) {
+ if (!mapUnique(remapper, id, packageName, parentPackage + "/package")) {
break;
}
+
packageName = parentPackage;
}
- remapper.mapPackage(packageName, "package" + id.getAndIncrement());
+
+ mapUnique(remapper, id, packageName, "package");
}
});
}
+ protected static boolean mapUnique(CustomRemapper remapper, AtomicInteger id, String oldName, String newName) {
+ if (remapper.packageMappingExists(oldName)) {
+ return false;
+ }
+ String mappedName;
+ do {
+ mappedName = newName + id.getAndIncrement();
+ } while (!remapper.mapPackage(oldName, mappedName));
+ return true;
+ }
+
public static class Config extends AbstractNormalizer.Config {
public Config() {
super(PackageNormalizer.class);
diff --git a/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/SourceFileClassNormalizer.java b/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/SourceFileClassNormalizer.java
index 06711f6e..13279aba 100644
--- a/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/SourceFileClassNormalizer.java
+++ b/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/SourceFileClassNormalizer.java
@@ -32,6 +32,9 @@ public void remap(CustomRemapper remapper) {
if (classNode.sourceFile == null) {
return;
}
+ if (remapper.mappingExists(classNode.name)){
+ return;
+ }
// todo handle inner classes gracefully (can we give them numerical ids? don't forget about nested inner classes)
String packageName = classNode.name.contains("/") ? classNode.name.substring(0, classNode.name.lastIndexOf('/')) : "";
diff --git a/src/test/java/com/javadeobfuscator/deobfuscator/TestJarBuilder.java b/src/test/java/com/javadeobfuscator/deobfuscator/TestJarBuilder.java
new file mode 100644
index 00000000..8363c75a
--- /dev/null
+++ b/src/test/java/com/javadeobfuscator/deobfuscator/TestJarBuilder.java
@@ -0,0 +1,252 @@
+package com.javadeobfuscator.deobfuscator;
+
+import org.objectweb.asm.ClassWriter;
+import org.objectweb.asm.MethodVisitor;
+import org.objectweb.asm.Opcodes;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+public class TestJarBuilder {
+
+ public static Map fixtureA() {
+ return jarWithClass("fixture/A", buildFixtureA("fixture/A", new String[0]));
+ }
+
+ public static Map fixtureAPrime() {
+ return jarWithClass("fixture/A", buildFixtureA("fixture/A", new String[]{"newMethod"}));
+ }
+
+ private static Map jarWithClass(String internalName, byte[] bytes) {
+ Map entries = new LinkedHashMap<>();
+ entries.put(internalName + ".class", bytes);
+ return entries;
+ }
+
+ private static byte[] buildFixtureA(String internalName, String[] extraMethods) {
+ ClassWriter cw = new ClassWriter(0);
+ cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC | Opcodes.ACC_SUPER, internalName, null, "java/lang/Object", null);
+
+ MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null);
+ mv.visitCode();
+ mv.visitVarInsn(Opcodes.ALOAD, 0);
+ mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V", false);
+ mv.visitInsn(Opcodes.RETURN);
+ mv.visitMaxs(1, 1);
+ mv.visitEnd();
+
+ for (String extra : extraMethods) {
+ mv = cw.visitMethod(Opcodes.ACC_PUBLIC, extra, "()V", null, null);
+ mv.visitCode();
+ mv.visitInsn(Opcodes.RETURN);
+ mv.visitMaxs(0, 1);
+ mv.visitEnd();
+ }
+
+ mv = cw.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "premain", "([Ljava/lang/String;)V", null, null);
+ mv.visitCode();
+ mv.visitInsn(Opcodes.RETURN);
+ mv.visitMaxs(0, 1);
+ mv.visitEnd();
+
+ mv = cw.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "values", "()I", null, null);
+ mv.visitCode();
+ mv.visitInsn(Opcodes.ICONST_0);
+ mv.visitInsn(Opcodes.IRETURN);
+ mv.visitMaxs(1, 0);
+ mv.visitEnd();
+
+ mv = cw.visitMethod(Opcodes.ACC_PUBLIC, "ˎᖺ", "()V", null, null);
+ mv.visitCode();
+ mv.visitInsn(Opcodes.RETURN);
+ mv.visitMaxs(0, 1);
+ mv.visitEnd();
+
+ cw.visitEnd();
+ return cw.toByteArray();
+ }
+
+ public static Map fixtureMethodAscii() {
+ return jarWithClass("fixture/A", buildMethodAsciiFixture("fixture/A"));
+ }
+
+ private static byte[] buildMethodAsciiFixture(String internalName) {
+ ClassWriter cw = new ClassWriter(0);
+ cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC | Opcodes.ACC_SUPER, internalName, null, "java/lang/Object", null);
+
+ MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null);
+ mv.visitCode();
+ mv.visitVarInsn(Opcodes.ALOAD, 0);
+ mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V", false);
+ mv.visitInsn(Opcodes.RETURN);
+ mv.visitMaxs(1, 1);
+ mv.visitEnd();
+
+ mv = cw.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "premain", "([Ljava/lang/String;)V", null, null);
+ mv.visitCode();
+ mv.visitInsn(Opcodes.RETURN);
+ mv.visitMaxs(0, 1);
+ mv.visitEnd();
+
+ mv = cw.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "values", "()I", null, null);
+ mv.visitCode();
+ mv.visitInsn(Opcodes.ICONST_0);
+ mv.visitInsn(Opcodes.IRETURN);
+ mv.visitMaxs(1, 0);
+ mv.visitEnd();
+
+ mv = cw.visitMethod(Opcodes.ACC_PUBLIC, "ˎᖺ", "()V", null, null);
+ mv.visitCode();
+ mv.visitInsn(Opcodes.RETURN);
+ mv.visitMaxs(0, 1);
+ mv.visitEnd();
+
+ mv = cw.visitMethod(Opcodes.ACC_PUBLIC, "ĭ", "()I", null, null);
+ mv.visitCode();
+ mv.visitInsn(Opcodes.ICONST_1);
+ mv.visitInsn(Opcodes.IRETURN);
+ mv.visitMaxs(1, 0);
+ mv.visitEnd();
+
+ cw.visitEnd();
+ return cw.toByteArray();
+ }
+
+ public static Map fixtureB() {
+ ClassWriter cw = new ClassWriter(0);
+ cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC | Opcodes.ACC_SUPER, "fixture/B", null, "java/lang/Object", null);
+
+ MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null);
+ mv.visitCode();
+ mv.visitVarInsn(Opcodes.ALOAD, 0);
+ mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V", false);
+ mv.visitInsn(Opcodes.RETURN);
+ mv.visitMaxs(1, 1);
+ mv.visitEnd();
+
+ cw.visitField(Opcodes.ACC_PRIVATE, "count", "I", null, null).visitEnd();
+ cw.visitField(Opcodes.ACC_PRIVATE, "ˏᖭ", "J", null, null).visitEnd();
+
+ cw.visitEnd();
+ return jarWithClass("fixture/B", cw.toByteArray());
+ }
+
+ public static Map fixtureWithSourceFile() {
+ ClassWriter cw = new ClassWriter(0);
+ cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC | Opcodes.ACC_SUPER, "fixture/A", null, "java/lang/Object", null);
+ cw.visitSource("A.java", null);
+
+ MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null);
+ mv.visitCode();
+ mv.visitVarInsn(Opcodes.ALOAD, 0);
+ mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V", false);
+ mv.visitInsn(Opcodes.RETURN);
+ mv.visitMaxs(1, 1);
+ mv.visitEnd();
+
+ cw.visitEnd();
+ return jarWithClass("fixture/A", cw.toByteArray());
+ }
+
+ public static Map fixtureCaseCollision() {
+ Map entries = new LinkedHashMap<>();
+ entries.put("fixture/A.class", buildSimpleClass("fixture/A"));
+ entries.put("fixture/a.class", buildSimpleClass("fixture/a"));
+ return entries;
+ }
+
+ public static Map fixtureCaseCollisionWithLiteralSuffix() {
+ Map entries = new LinkedHashMap<>();
+ entries.put("fixture/OriginalName.class", buildSimpleClass("fixture/OriginalName"));
+ entries.put("fixture/originalname.class", buildSimpleClass("fixture/originalname"));
+ entries.put("fixture/OriginalName_0.class", buildSimpleClass("fixture/OriginalName_0"));
+ return entries;
+ }
+
+ public static Map fixtureCaseCollisionWithFoo() {
+ Map entries = new LinkedHashMap<>();
+ entries.put("fixture/OriginalName.class", buildSimpleClass("fixture/OriginalName"));
+ entries.put("fixture/originalname.class", buildSimpleClass("fixture/originalname"));
+ entries.put("fixture/foo.class", buildSimpleClass("fixture/foo"));
+ return entries;
+ }
+
+ public static Map fixtureExistingNameWithFoo() {
+ Map entries = new LinkedHashMap<>();
+ entries.put("fixture/OriginalName.class", buildSimpleClass("fixture/OriginalName"));
+ entries.put("fixture/foo.class", buildSimpleClass("fixture/foo"));
+ return entries;
+ }
+
+ public static Map fixtureDuplicateMethodAndField() {
+ ClassWriter cw = new ClassWriter(0);
+ cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC | Opcodes.ACC_SUPER, "fixture/Dup", null, "java/lang/Object", null);
+
+ MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null);
+ mv.visitCode();
+ mv.visitVarInsn(Opcodes.ALOAD, 0);
+ mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V", false);
+ mv.visitInsn(Opcodes.RETURN);
+ mv.visitMaxs(1, 1);
+ mv.visitEnd();
+
+ mv = cw.visitMethod(Opcodes.ACC_PUBLIC, "m", "()V", null, null);
+ mv.visitCode();
+ mv.visitInsn(Opcodes.RETURN);
+ mv.visitMaxs(0, 1);
+ mv.visitEnd();
+
+ mv = cw.visitMethod(Opcodes.ACC_PUBLIC, "m", "()I", null, null);
+ mv.visitCode();
+ mv.visitInsn(Opcodes.ICONST_0);
+ mv.visitInsn(Opcodes.IRETURN);
+ mv.visitMaxs(1, 1);
+ mv.visitEnd();
+
+ cw.visitField(Opcodes.ACC_PRIVATE, "f", "I", null, null).visitEnd();
+ cw.visitField(Opcodes.ACC_PRIVATE, "f", "J", null, null).visitEnd();
+ cw.visitEnd();
+
+ Map entries = new LinkedHashMap<>();
+ entries.put("fixture/Dup.class", cw.toByteArray());
+ return entries;
+ }
+
+ public static Map fixtureKeepClasses() {
+ Map entries = new LinkedHashMap<>();
+ entries.put("fixture/Keep.class", buildSimpleClass("fixture/Keep"));
+ entries.put("fixture/ˎᖺ.class", buildSimpleClass("fixture/ˎᖺ"));
+ return entries;
+ }
+
+ private static byte[] buildSimpleClass(String internalName) {
+ ClassWriter cw = new ClassWriter(0);
+ cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC | Opcodes.ACC_SUPER, internalName, null, "java/lang/Object", null);
+
+ MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null);
+ mv.visitCode();
+ mv.visitVarInsn(Opcodes.ALOAD, 0);
+ mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V", false);
+ mv.visitInsn(Opcodes.RETURN);
+ mv.visitMaxs(1, 1);
+ mv.visitEnd();
+
+ cw.visitEnd();
+ return cw.toByteArray();
+ }
+
+ public static void writeJar(File jar, Map entries) throws IOException {
+ try (ZipOutputStream out = new ZipOutputStream(new FileOutputStream(jar))) {
+ for (Map.Entry entry : entries.entrySet()) {
+ out.putNextEntry(new ZipEntry(entry.getKey()));
+ out.write(entry.getValue());
+ out.closeEntry();
+ }
+ }
+ }
+}
diff --git a/src/test/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/CustomRemapperConsolidationTest.java b/src/test/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/CustomRemapperConsolidationTest.java
index 3b516776..275df86f 100644
--- a/src/test/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/CustomRemapperConsolidationTest.java
+++ b/src/test/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/CustomRemapperConsolidationTest.java
@@ -75,14 +75,6 @@ public void classRetryLoopTerminatesAndStaysConsistent() {
assertEquals("a/b/Class1", r.map("a/b/D"));
}
- @Test
- public void packageGuardIsBothDirections() {
- CustomRemapper r = remapper();
- assertTrue(r.mapPackage("a/b", "x/y"));
- assertFalse(r.mapPackage("a/b", "p/q")); // old already mapped
- assertFalse(r.mapPackage("p/r", "x/y")); // target taken
- }
-
@Test
public void memberRoundTrip() {
CustomRemapper r = remapper();
diff --git a/src/test/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/DuplicateClassCollisionTest.java b/src/test/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/DuplicateClassCollisionTest.java
new file mode 100644
index 00000000..90b36925
--- /dev/null
+++ b/src/test/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/DuplicateClassCollisionTest.java
@@ -0,0 +1,258 @@
+package com.javadeobfuscator.deobfuscator.transformers.normalizer;
+
+import com.javadeobfuscator.deobfuscator.Deobfuscator;
+import com.javadeobfuscator.deobfuscator.TestJarBuilder;
+import com.javadeobfuscator.deobfuscator.config.Configuration;
+import com.javadeobfuscator.deobfuscator.config.TransformerConfig;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.objectweb.asm.ClassReader;
+import org.objectweb.asm.tree.ClassNode;
+
+import java.io.File;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Collision semantics for the class section of {@link DuplicateRenamer}.
+ * The allocated suffix name must be checked — case-insensitively, the
+ * normalizer's domain — against every name already claimed this run: kept
+ * names of other classes, loaded class-mapping targets, and earlier
+ * suffixes. The current implementation only refuses exact targets in the
+ * reversed map, so a suffix lands on a case-variant (or even an exact name)
+ * that an earlier class or a loaded mapping owns, silently dropping one
+ * class from the output jar.
+ *
+ * Note on determinism: the class section iterates the deobfuscator's
+ * HashMap, whose order for these fixture keys is stable per JVM; the
+ * collision the tests pin occurs in the observed order, and the fixed
+ * implementation removes the order sensitivity entirely.
+ */
+public class DuplicateClassCollisionTest {
+
+ @Rule
+ public TemporaryFolder tempFolder = new TemporaryFolder();
+
+ /**
+ * The collision pair fixture/OriginalName + fixture/originalname and the
+ * literal fixture/OriginalName_0: whichever member of the pair is bumped,
+ * its first suffix is the case-variant of the other member's suffix — so
+ * in one order it collides exactly (class dropped), in the other it
+ * collides case-insensitively. Both must be impossible.
+ */
+ @Test(timeout = 20000)
+ public void testSuffixAllocationAvoidsLiteralClassName() throws Throwable {
+ File in = writeJar("in.jar", TestJarBuilder.fixtureCaseCollisionWithLiteralSuffix());
+ File out = new File(tempFolder.newFolder(), "out.jar");
+ run(in, out, new DuplicateRenamer.Config());
+
+ List names = outputClassNames(out);
+ assertEquals("three classes survive", 3, names.size());
+ assertCaseDistinct(names);
+ }
+
+ /**
+ * Duplicate of fixture/OriginalName is loaded-mapped to the case-variant
+ * fixture/originalname_0. The bump (observed order: lowercase twin kept
+ * first) lands on fixture/OriginalName_0, which is not in the reversed
+ * map (the loaded target is the other case), so it slips through and
+ * collides case-insensitively with the loaded target.
+ */
+ @Test(timeout = 20000)
+ public void testSuffixAllocationAvoidsLoadedTargetCaseVariant() throws Throwable {
+ File in = writeJar("in.jar", TestJarBuilder.fixtureCaseCollisionWithFoo());
+ File mapping = mappingFile("{\"classes\": {\"fixture/foo\": \"fixture/originalname_0\"}}");
+
+ File out = new File(tempFolder.newFolder(), "out.jar");
+ DuplicateRenamer.Config config = new DuplicateRenamer.Config();
+ config.setMappingInput(mapping);
+ run(in, out, config);
+
+ List names = outputClassNames(out);
+ assertEquals("three classes survive", 3, names.size());
+ assertCaseDistinct(names);
+ assertTrue("loaded target is used verbatim", names.contains("fixture/originalname_0"));
+ assertFalse("duplicate bump did not land on the loaded target's case-variant",
+ names.contains("fixture/OriginalName_0"));
+ }
+
+ /**
+ * The loaded target fixture/foo -> fixture/OriginalName is a class that
+ * exists in the jar. The existing class must give way: without an
+ * occupied-name check, both classes write the same entry and one is
+ * silently dropped.
+ */
+ @Test(timeout = 20000)
+ public void testExistingClassGivesWayToLoadedTarget() throws Throwable {
+ File in = writeJar("in.jar", TestJarBuilder.fixtureExistingNameWithFoo());
+ File mapping = mappingFile("{\"classes\": {\"fixture/foo\": \"fixture/OriginalName\"}}");
+
+ File out = new File(tempFolder.newFolder(), "out.jar");
+ DuplicateRenamer.Config config = new DuplicateRenamer.Config();
+ config.setMappingInput(mapping);
+ run(in, out, config);
+
+ List names = outputClassNames(out);
+ assertEquals("two classes survive", 2, names.size());
+ assertCaseDistinct(names);
+ assertEquals("loaded target owns fixture/OriginalName, the existing class moved aside",
+ 1, names.stream().filter(n -> n.equalsIgnoreCase("fixture/OriginalName")).count());
+ }
+
+ /**
+ * Run 1 renames and dumps; run 2 consumes the mapping. Both runs must
+ * produce identical, collision-free output.
+ */
+ @Test(timeout = 20000)
+ public void testDuplicateRenameRoundTripStable() throws Throwable {
+ File mapping = new File(tempFolder.newFolder(), "mapping.json");
+
+ File in1 = writeJar("in.jar", TestJarBuilder.fixtureCaseCollisionWithLiteralSuffix());
+ File out1 = new File(tempFolder.newFolder(), "out1.jar");
+ DuplicateRenamer.Config cfg1 = new DuplicateRenamer.Config();
+ cfg1.setMappingOutput(mapping);
+ run(in1, out1, cfg1);
+ List run1 = outputClassNames(out1);
+ assertEquals("run 1 keeps all three classes", 3, run1.size());
+ assertCaseDistinct(run1);
+
+ File in2 = writeJar("in2.jar", TestJarBuilder.fixtureCaseCollisionWithLiteralSuffix());
+ File out2 = new File(tempFolder.newFolder(), "out2.jar");
+ DuplicateRenamer.Config cfg2 = new DuplicateRenamer.Config();
+ cfg2.setMappingInput(mapping);
+ run(in2, out2, cfg2);
+
+ assertEquals("run 2 reproduces run 1's names exactly", run1, outputClassNames(out2));
+ }
+
+ /**
+ * The method and field sections of DuplicateRenamer guard their bump loops
+ * with {@code methodMappingExists}/{@code fieldMappingExists} (unlike the
+ * class section), so a consume-run must reproduce run 1's suffixed duplicate
+ * method and field names instead of re-aliasing them.
+ */
+ @Test(timeout = 20000)
+ public void testDuplicateRenamerMethodFieldRoundTripStable() throws Throwable {
+ File mapping = new File(tempFolder.newFolder(), "mapping.json");
+
+ File in1 = writeJar("in.jar", TestJarBuilder.fixtureDuplicateMethodAndField());
+ File out1 = new File(tempFolder.newFolder(), "out1.jar");
+ DuplicateRenamer.Config cfg1 = new DuplicateRenamer.Config();
+ cfg1.setMappingOutput(mapping);
+ run(in1, out1, cfg1);
+ // A same-name different-return method is a within-class conflict, resolved
+ // by the conflict section which renames both occurrences deterministically.
+ assertEquals(Arrays.asList("", "m_0", "m_1"), methodNames(out1, "fixture/Dup.class"));
+ assertEquals(Arrays.asList("f", "f_0"), fieldNames(out1, "fixture/Dup.class"));
+
+ File in2 = writeJar("in2.jar", TestJarBuilder.fixtureDuplicateMethodAndField());
+ File out2 = new File(tempFolder.newFolder(), "out2.jar");
+ DuplicateRenamer.Config cfg2 = new DuplicateRenamer.Config();
+ cfg2.setMappingInput(mapping);
+ run(in2, out2, cfg2);
+
+ assertEquals("run 2 reproduces run 1's duplicate method names",
+ Arrays.asList("", "m_0", "m_1"), methodNames(out2, "fixture/Dup.class"));
+ assertEquals("run 2 reproduces run 1's duplicate field names",
+ Arrays.asList("f", "f_0"), fieldNames(out2, "fixture/Dup.class"));
+ }
+
+ @Test()
+ public void testDuplicateRenamerSkipsConsumedClassMapping() throws Throwable {
+ File in = new File(tempFolder.newFolder(), "in.jar");
+ TestJarBuilder.writeJar(in, TestJarBuilder.fixtureCaseCollision());
+
+ File mapping = new File(tempFolder.newFolder(), "mapping.json");
+ Files.write(mapping.toPath(),
+ ("{\"classes\": {\"fixture/A\": \"fixture/LoadedA\", \"fixture/a\": \"fixture/LoadedB\"}}")
+ .getBytes(StandardCharsets.UTF_8));
+
+ File out = new File(tempFolder.newFolder(), "out.jar");
+ DuplicateRenamer.Config config = new DuplicateRenamer.Config();
+ config.setMappingInput(mapping);
+ run(in, out, config);
+
+ assertEquals("loaded mapping honored, class not re-renamed",
+ "fixture/LoadedA", readClass(out, "fixture/LoadedA.class").name);
+ assertEquals("loaded mapping honored, class not re-renamed",
+ "fixture/LoadedB", readClass(out, "fixture/LoadedB.class").name);
+ }
+
+ private File mappingFile(String json) throws Exception {
+ File mapping = new File(tempFolder.newFolder(), "mapping.json");
+ Files.write(mapping.toPath(), json.getBytes(StandardCharsets.UTF_8));
+ return mapping;
+ }
+
+ private File writeJar(String name, java.util.Map entries) throws Exception {
+ File jar = new File(tempFolder.newFolder(), name);
+ TestJarBuilder.writeJar(jar, entries);
+ return jar;
+ }
+
+ private static void run(File input, File output, TransformerConfig config) throws Throwable {
+ Configuration cfg = new Configuration();
+ cfg.setInput(input);
+ cfg.setOutput(output);
+ cfg.setTransformers(Arrays.asList(config));
+ new Deobfuscator(cfg).start();
+ }
+
+ private static List outputClassNames(File jar) throws Exception {
+ try (ZipFile zip = new ZipFile(jar)) {
+ List names = new ArrayList<>();
+ for (Enumeration extends ZipEntry> e = zip.entries(); e.hasMoreElements(); ) {
+ ZipEntry entry = e.nextElement();
+ if (!entry.getName().endsWith(".class")) {
+ continue;
+ }
+ try (InputStream in = zip.getInputStream(entry)) {
+ ClassNode node = new ClassNode();
+ new ClassReader(in).accept(node, 0);
+ names.add(node.name);
+ }
+ }
+ Collections.sort(names);
+ return names;
+ }
+ }
+
+ private static ClassNode readClass(File jar, String entry) throws Exception {
+ try (ZipFile zip = new ZipFile(jar); InputStream in = zip.getInputStream(zip.getEntry(entry))) {
+ ClassNode node = new ClassNode();
+ new ClassReader(in).accept(node, 0);
+ return node;
+ }
+ }
+
+ private static List methodNames(File jar, String entry) throws Exception {
+ return readClass(jar, entry).methods.stream().map(m -> m.name).collect(Collectors.toList());
+ }
+
+ private static List fieldNames(File jar, String entry) throws Exception {
+ return readClass(jar, entry).fields.stream().map(f -> f.name).collect(Collectors.toList());
+ }
+
+ private static void assertCaseDistinct(List names) {
+ Set lower = names.stream()
+ .map(n -> n.toLowerCase(Locale.ROOT))
+ .collect(Collectors.toSet());
+ assertEquals("no two classes may share a case-insensitive name", names.size(), lower.size());
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/NormalizerLifecycleTest.java b/src/test/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/NormalizerLifecycleTest.java
new file mode 100644
index 00000000..a19a823e
--- /dev/null
+++ b/src/test/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/NormalizerLifecycleTest.java
@@ -0,0 +1,218 @@
+package com.javadeobfuscator.deobfuscator.transformers.normalizer;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.javadeobfuscator.deobfuscator.Deobfuscator;
+import com.javadeobfuscator.deobfuscator.TestJarBuilder;
+import com.javadeobfuscator.deobfuscator.config.Configuration;
+import com.javadeobfuscator.deobfuscator.config.TransformerConfig;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.objectweb.asm.ClassReader;
+import org.objectweb.asm.tree.ClassNode;
+
+import java.io.File;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+public class NormalizerLifecycleTest {
+
+ @Rule
+ public TemporaryFolder tempFolder = new TemporaryFolder();
+
+ @Test
+ public void testWritesMappingOutput() throws Throwable {
+ File fixture = writeJar("fixture.jar", TestJarBuilder.fixtureA());
+ File out1 = new File(tempFolder.newFolder(), "out1.jar");
+ File mapping = new File(tempFolder.newFolder(), "mapping.json");
+
+ MethodNormalizer.Config normalizerConfig = new MethodNormalizer.Config();
+ normalizerConfig.setMappingOutput(mapping);
+ run(fixture, out1, normalizerConfig);
+
+ assertTrue("mapping output file should be written", mapping.exists());
+ JsonNode root = new ObjectMapper().readTree(mapping);
+ JsonNode methods = root.get("methods");
+ assertNotNull("mapping JSON should contain methods", methods);
+ assertFalse("mapping methods should not be empty", methods.isEmpty());
+ JsonNode fixtureMappings = methods.get("fixture/A");
+ assertNotNull("mapping should contain owner fixture/A", fixtureMappings);
+ JsonNode obfuscatedMethod = null;
+ for (JsonNode e : fixtureMappings) {
+ if ("ˎᖺ".equals(e.get("name").asText()) && "()V".equals(e.get("desc").asText())) {
+ obfuscatedMethod = e.get("to");
+ break;
+ }
+ }
+ assertNotNull("mapping should contain ˎᖺ()V", obfuscatedMethod);
+ assertEquals("Method2", obfuscatedMethod.asText());
+
+ assertEquals(Arrays.asList("", "Method0", "Method1", "Method2"),
+ methodNames(out1, "fixture/A.class"));
+ }
+
+ @Test
+ public void testConsumesMapping() throws Throwable {
+ // Run 1: fixture A (declaration order: , premain, values, ˎᖺ), no mapping consumed.
+ // premain/values/ˎᖺ are renamed to Method0/1/2; the mapping is dumped for runs 2a/2b.
+
+ File fixtureA = writeJar("fixtureA.jar", TestJarBuilder.fixtureA());
+ File out1 = new File(tempFolder.newFolder(), "out1.jar");
+ File mapping = new File(tempFolder.newFolder(), "mapping.json");
+ MethodNormalizer.Config cfg = new MethodNormalizer.Config();
+ cfg.setMappingOutput(mapping);
+ run(fixtureA, out1, cfg);
+ assertEquals(Arrays.asList("", "Method0", "Method1", "Method2"),
+ methodNames(out1, "fixture/A.class"));
+
+ // Runs 2a/2b share fixture A' = A with newMethod inserted at the front:
+ // , newMethod, premain, values, ˎᖺ. premain is at index 2 in both outputs,
+ // so its name discriminates consume (2a) from re-derivation (2b).
+ File fixtureAPrime = writeJar("fixtureAPrime.jar", TestJarBuilder.fixtureAPrime());
+
+ // Run 2a: consume run 1's mapping. premain/values/ˎᖺ are taken verbatim (Method0/1/2).
+ // newMethod is not in the mapping; the fresh counter (0) accepts "Method0" because the
+ // collision check is scoped to name+desc per owner (CustomRemapper.mapMethodName) and
+ // "Method0()V" is not among the loaded values (Method0([Ljava/lang/String;)V, Method1()I,
+ // Method2()V). premain at index 2 is the discriminator: here it stays Method0 from the
+ // mapping, whereas run 2b re-derives it to Method1.
+ File out2a = new File(tempFolder.newFolder(), "out2a.jar");
+ cfg = new MethodNormalizer.Config();
+ cfg.setMappingInput(mapping);
+ run(fixtureAPrime, out2a, cfg);
+ assertEquals(Arrays.asList("", "Method0", "Method0", "Method1", "Method2"),
+ methodNames(out2a, "fixture/A.class"));
+
+ // Run 2b: same input as 2a, no mapping. The fresh counter re-derives everything:
+ // newMethod takes Method0, pushing premain to Method1. premain differs from 2a,
+ // so the mapping file drove 2a's outcome.
+ File out2b = new File(tempFolder.newFolder(), "out2b.jar");
+ cfg = new MethodNormalizer.Config();
+ run(fixtureAPrime, out2b, cfg);
+ assertEquals(Arrays.asList("", "Method0", "Method1", "Method2", "Method3"),
+ methodNames(out2b, "fixture/A.class"));
+ }
+
+ @Test
+ public void testClassMappingFileHonored() throws Throwable {
+ File in = writeJar("in.jar", TestJarBuilder.fixtureKeepClasses());
+
+ File mapping = new File(tempFolder.newFolder(), "mapping.json");
+ java.nio.file.Files.write(mapping.toPath(),
+ ("{\"classes\": {\"fixture/ˎᖺ\": \"fixture/renamed\"}}").getBytes(java.nio.charset.StandardCharsets.UTF_8));
+
+ File out = new File(tempFolder.newFolder(), "out.jar");
+ ClassNormalizer.Config cfg = new ClassNormalizer.Config();
+ cfg.setMappingInput(mapping);
+ run(in, out, cfg);
+
+ assertEquals("mapped class keeps the loaded name, not Class{n}",
+ "fixture/renamed", readClass(out, "fixture/renamed.class").name);
+ System.out.println(readClass(out, "fixture/Class0.class"));
+ assertEquals("unmapped class still gets Class0",
+ "fixture/Class0", readClass(out, "fixture/Class0.class").name);
+ }
+
+ @Test
+ public void testFieldConsumeStable() throws Throwable {
+ File in1 = writeJar("in.jar", TestJarBuilder.fixtureB());
+ File out1 = new File(tempFolder.newFolder(), "out1.jar");
+ File mapping = new File(tempFolder.newFolder(), "mapping.json");
+ FieldNormalizer.Config cfg = new FieldNormalizer.Config();
+ cfg.setMappingOutput(mapping);
+ run(in1, out1, cfg);
+ assertEquals(Arrays.asList("Field0", "Field1"), fieldNames(out1, "fixture/B.class"));
+
+ File in2 = writeJar("in2.jar", TestJarBuilder.fixtureB());
+ File out2 = new File(tempFolder.newFolder(), "out2.jar");
+ cfg = new FieldNormalizer.Config();
+ cfg.setMappingInput(mapping);
+ run(in2, out2, cfg);
+ assertEquals("loaded field mappings reproduced",
+ Arrays.asList("Field0", "Field1"), fieldNames(out2, "fixture/B.class"));
+ }
+
+ @Test
+ public void testPackageConsumeStable() throws Throwable {
+ File in1 = writeJar("in.jar", TestJarBuilder.fixtureA());
+ File out1 = new File(tempFolder.newFolder(), "out1.jar");
+ File mapping = new File(tempFolder.newFolder(), "mapping.json");
+ PackageNormalizer.Config cfg = new PackageNormalizer.Config();
+ cfg.setMappingOutput(mapping);
+ run(in1, out1, cfg);
+ assertEquals("fixture is renamed to package0",
+ "package0/A", readClass(out1, "package0/A.class").name);
+
+ File in2 = writeJar("in2.jar", TestJarBuilder.fixtureA());
+ File out2 = new File(tempFolder.newFolder(), "out2.jar");
+ cfg = new PackageNormalizer.Config();
+ cfg.setMappingInput(mapping);
+ run(in2, out2, cfg);
+ assertEquals("run 2 reproduces package0, not a drifted package1",
+ "package0/A", readClass(out2, "package0/A.class").name);
+ }
+
+ private File writeJar(String name, java.util.Map entries) throws Exception {
+ File jar = new File(tempFolder.newFolder(), name);
+ TestJarBuilder.writeJar(jar, entries);
+ return jar;
+ }
+
+ private static void run(File input, File output, TransformerConfig config) throws Throwable {
+ Configuration cfg = new Configuration();
+ cfg.setInput(input);
+ cfg.setOutput(output);
+ cfg.setTransformers(Arrays.asList(config));
+ new Deobfuscator(cfg).start();
+ }
+
+ private static List methodNames(File jar, String entryName) throws Exception {
+ return readClass(jar, entryName).methods.stream().map(m -> m.name).collect(Collectors.toList());
+ }
+
+ private static List fieldNames(File jar, String entryName) throws Exception {
+ return readClass(jar, entryName).fields.stream().map(f -> f.name).collect(Collectors.toList());
+ }
+
+ private static ClassNode readClass(File jar, String entryName) throws Exception {
+ try (ZipFile zip = new ZipFile(jar)) {
+ try (InputStream in = zip.getInputStream(zip.getEntry(entryName))) {
+ ClassNode node = new ClassNode();
+ new ClassReader(in).accept(node, 0);
+ return node;
+ }
+ }
+ }
+
+ @Test()
+ public void testSourceFileNormalizerSkipsConsumedClassMapping() throws Throwable {
+ File in = new File(tempFolder.newFolder(), "in.jar");
+ TestJarBuilder.writeJar(in, TestJarBuilder.fixtureWithSourceFile());
+
+ File mapping = new File(tempFolder.newFolder(), "mapping.json");
+ Files.write(mapping.toPath(),
+ "{\"classes\": {\"fixture/A\": \"fixture/Loaded\"}}".getBytes(StandardCharsets.UTF_8));
+
+ File out = new File(tempFolder.newFolder(), "out.jar");
+ SourceFileClassNormalizer.Config config = new SourceFileClassNormalizer.Config();
+ config.setMappingInput(mapping);
+ run(in, out, config);
+
+ assertEquals("loaded mapping honored, class not re-renamed",
+ "fixture/Loaded", readClass(out, "fixture/Loaded.class").name);
+ }
+}
From 3df4e12d3d5bfdd11db407e590deb60cab87a47f Mon Sep 17 00:00:00 2001
From: Jona
Date: Thu, 10 Sep 2026 02:29:35 +0200
Subject: [PATCH 5/5] Add option to keep ASCII names
---
.../AbstractAsciiKeepingNormalizer.java | 35 +++++++++
.../normalizer/ClassNormalizer.java | 9 ++-
.../normalizer/EnumNormalizer.java | 6 +-
.../normalizer/FieldNormalizer.java | 7 +-
.../normalizer/MethodNormalizer.java | 7 +-
.../normalizer/PackageNormalizer.java | 12 ++-
.../deobfuscator/TestJarBuilder.java | 38 ++++++++++
.../normalizer/NormalizerLifecycleTest.java | 75 +++++++++++++++++++
8 files changed, 177 insertions(+), 12 deletions(-)
create mode 100644 src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/AbstractAsciiKeepingNormalizer.java
diff --git a/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/AbstractAsciiKeepingNormalizer.java b/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/AbstractAsciiKeepingNormalizer.java
new file mode 100644
index 00000000..cfbfa499
--- /dev/null
+++ b/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/AbstractAsciiKeepingNormalizer.java
@@ -0,0 +1,35 @@
+package com.javadeobfuscator.deobfuscator.transformers.normalizer;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.javadeobfuscator.deobfuscator.config.TransformerConfig;
+import com.javadeobfuscator.deobfuscator.transformers.Transformer;
+
+abstract public class AbstractAsciiKeepingNormalizer extends AbstractNormalizer{
+ protected static boolean containsAscii(String name) {
+ for (int i = 0; i < name.length(); i++) {
+ if (name.charAt(i) < 0x80) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+
+ public static abstract class Config extends AbstractNormalizer.Config {
+ @JsonProperty(value = "keep-ascii-names")
+ private boolean keepAsciiNames;
+
+ public Config(Class extends Transformer>> implementation) {
+ super(implementation);
+ }
+
+ public boolean isKeepAsciiNames() {
+ return keepAsciiNames;
+ }
+
+ public void setKeepAsciiNames(boolean keepAsciiNames) {
+ this.keepAsciiNames = keepAsciiNames;
+ }
+
+ }
+}
diff --git a/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/ClassNormalizer.java b/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/ClassNormalizer.java
index 459a9412..58ac918e 100644
--- a/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/ClassNormalizer.java
+++ b/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/ClassNormalizer.java
@@ -21,7 +21,7 @@
import java.util.concurrent.atomic.AtomicInteger;
@TransformerConfig.ConfigOptions(configClass = ClassNormalizer.Config.class)
-public class ClassNormalizer extends AbstractNormalizer {
+public class ClassNormalizer extends AbstractAsciiKeepingNormalizer {
@Override
public void remap(CustomRemapper remapper) {
AtomicInteger id = new AtomicInteger(0);
@@ -31,6 +31,11 @@ public void remap(CustomRemapper remapper) {
return;
}
+ if (getConfig().isKeepAsciiNames()
+ && containsAscii(classNode.name.substring(classNode.name.lastIndexOf('/') + 1))) {
+ return;
+ }
+
String newName = "Class";
if(classNode.name.contains("/")){
@@ -46,7 +51,7 @@ public void remap(CustomRemapper remapper) {
});
}
- public static class Config extends AbstractNormalizer.Config {
+ public static class Config extends AbstractAsciiKeepingNormalizer.Config {
public Config() {
super(ClassNormalizer.class);
}
diff --git a/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/EnumNormalizer.java b/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/EnumNormalizer.java
index c8112b2c..198cf88b 100644
--- a/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/EnumNormalizer.java
+++ b/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/EnumNormalizer.java
@@ -21,7 +21,7 @@
import java.util.Map.Entry;
@TransformerConfig.ConfigOptions(configClass = EnumNormalizer.Config.class)
-public class EnumNormalizer extends AbstractNormalizer
+public class EnumNormalizer extends AbstractAsciiKeepingNormalizer
{
@Override
public void remap(CustomRemapper remapper)
@@ -138,7 +138,7 @@ public void remap(CustomRemapper remapper)
{
String value = (String)((LdcInsnNode)invokeFrame.getStack(invokeFrame.getStackSize() -
argLen).insns.iterator().next()).cst;
- if(!field.name.equals(value))
+ if(!field.name.equals(value) && !(getConfig().isKeepAsciiNames() && containsAscii(field.name)))
remapper.mapFieldName(classNode.name, field.name, field.desc, value, false);
}
}
@@ -147,7 +147,7 @@ public void remap(CustomRemapper remapper)
}
}
- public static class Config extends AbstractNormalizer.Config {
+ public static class Config extends AbstractAsciiKeepingNormalizer.Config {
public Config() {
super(EnumNormalizer.class);
}
diff --git a/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/FieldNormalizer.java b/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/FieldNormalizer.java
index 91ca03a7..cb10a6c2 100644
--- a/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/FieldNormalizer.java
+++ b/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/FieldNormalizer.java
@@ -27,7 +27,7 @@
import java.util.concurrent.atomic.AtomicInteger;
@TransformerConfig.ConfigOptions(configClass = FieldNormalizer.Config.class)
-public class FieldNormalizer extends AbstractNormalizer {
+public class FieldNormalizer extends AbstractAsciiKeepingNormalizer {
public static boolean EXCLUDE_ENUMS = true;
@@ -80,6 +80,9 @@ public void remap(CustomRemapper remapper) {
&& Type.getType(fieldNode.desc).getSort() == Type.OBJECT
&& Type.getType(fieldNode.desc).getInternalName().equals(classNode.name))
continue;
+ if (getConfig().isKeepAsciiNames() && containsAscii(fieldNode.name)) {
+ continue;
+ }
List references = new ArrayList<>();
for (String possibleClass : allClasses) {
ClassNode otherNode = this.getDeobfuscator().assureLoaded(possibleClass);
@@ -108,7 +111,7 @@ public void remap(CustomRemapper remapper) {
});
}
- public static class Config extends AbstractNormalizer.Config {
+ public static class Config extends AbstractAsciiKeepingNormalizer.Config {
public Config() {
super(FieldNormalizer.class);
}
diff --git a/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/MethodNormalizer.java b/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/MethodNormalizer.java
index 9b23d7f2..9e39d8d0 100644
--- a/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/MethodNormalizer.java
+++ b/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/MethodNormalizer.java
@@ -27,7 +27,7 @@
import java.util.concurrent.atomic.AtomicInteger;
@TransformerConfig.ConfigOptions(configClass = MethodNormalizer.Config.class)
-public class MethodNormalizer extends AbstractNormalizer {
+public class MethodNormalizer extends AbstractAsciiKeepingNormalizer {
@Override
public void remap(CustomRemapper remapper) {
@@ -289,6 +289,9 @@ public void remap(CustomRemapper remapper) {
});
if (!isLibrary.get()) {
+ if (getConfig().isKeepAsciiNames() && containsAscii(methodNode.name)) {
+ continue;
+ }
if (!remapper.methodMappingExists(classNode.name, methodNode.name, methodNode.desc)) {
while (true) {
String name = "Method" + id.getAndIncrement();
@@ -305,7 +308,7 @@ public void remap(CustomRemapper remapper) {
});
}
- public static class Config extends AbstractNormalizer.Config {
+ public static class Config extends AbstractAsciiKeepingNormalizer.Config {
public Config() {
super(MethodNormalizer.class);
}
diff --git a/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/PackageNormalizer.java b/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/PackageNormalizer.java
index 475dc7b7..74c372b3 100644
--- a/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/PackageNormalizer.java
+++ b/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/PackageNormalizer.java
@@ -21,7 +21,7 @@
import java.util.concurrent.atomic.AtomicInteger;
@TransformerConfig.ConfigOptions(configClass = PackageNormalizer.Config.class)
-public class PackageNormalizer extends AbstractNormalizer {
+public class PackageNormalizer extends AbstractAsciiKeepingNormalizer {
@Override
public void remap(CustomRemapper remapper) {
@@ -44,10 +44,16 @@ public void remap(CustomRemapper remapper) {
});
}
- protected static boolean mapUnique(CustomRemapper remapper, AtomicInteger id, String oldName, String newName) {
+ protected boolean mapUnique(CustomRemapper remapper, AtomicInteger id, String oldName, String newName) {
if (remapper.packageMappingExists(oldName)) {
return false;
}
+
+ if (getConfig().isKeepAsciiNames()
+ && containsAscii(oldName.substring(oldName.lastIndexOf('/') + 1))) {
+ return true;
+ }
+
String mappedName;
do {
mappedName = newName + id.getAndIncrement();
@@ -55,7 +61,7 @@ protected static boolean mapUnique(CustomRemapper remapper, AtomicInteger id, St
return true;
}
- public static class Config extends AbstractNormalizer.Config {
+ public static class Config extends AbstractAsciiKeepingNormalizer.Config {
public Config() {
super(PackageNormalizer.class);
}
diff --git a/src/test/java/com/javadeobfuscator/deobfuscator/TestJarBuilder.java b/src/test/java/com/javadeobfuscator/deobfuscator/TestJarBuilder.java
index 8363c75a..533e1b0e 100644
--- a/src/test/java/com/javadeobfuscator/deobfuscator/TestJarBuilder.java
+++ b/src/test/java/com/javadeobfuscator/deobfuscator/TestJarBuilder.java
@@ -240,6 +240,44 @@ private static byte[] buildSimpleClass(String internalName) {
return cw.toByteArray();
}
+ public static Map fixtureColor() {
+ ClassWriter cw = new ClassWriter(0);
+ cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC | Opcodes.ACC_SUPER | Opcodes.ACC_ENUM,
+ "fixture/Color", null, "java/lang/Enum", null);
+
+ cw.visitField(Opcodes.ACC_ENUM | Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC | Opcodes.ACC_FINAL,
+ "x", "Lfixture/Color;", null, null).visitEnd();
+ cw.visitField(Opcodes.ACC_ENUM | Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC | Opcodes.ACC_FINAL,
+ "ˎᖺ", "Lfixture/Color;", null, null).visitEnd();
+
+ MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PRIVATE, "", "(Ljava/lang/String;I)V", null, null);
+ mv.visitCode();
+ mv.visitVarInsn(Opcodes.ALOAD, 0);
+ mv.visitVarInsn(Opcodes.ALOAD, 1);
+ mv.visitVarInsn(Opcodes.ILOAD, 2);
+ mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Enum", "", "(Ljava/lang/String;I)V", false);
+ mv.visitInsn(Opcodes.RETURN);
+ mv.visitMaxs(3, 3);
+ mv.visitEnd();
+
+ mv = cw.visitMethod(Opcodes.ACC_STATIC, "", "()V", null, null);
+ mv.visitCode();
+ for (Object[] constant : new Object[][]{{"x", "REALNAME", Opcodes.ICONST_0}, {"ˎᖺ", "II", Opcodes.ICONST_1}}) {
+ mv.visitTypeInsn(Opcodes.NEW, "fixture/Color");
+ mv.visitInsn(Opcodes.DUP);
+ mv.visitLdcInsn(constant[1]);
+ mv.visitInsn((Integer) constant[2]);
+ mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "fixture/Color", "", "(Ljava/lang/String;I)V", false);
+ mv.visitFieldInsn(Opcodes.PUTSTATIC, "fixture/Color", (String) constant[0], "Lfixture/Color;");
+ }
+ mv.visitInsn(Opcodes.RETURN);
+ mv.visitMaxs(4, 0);
+ mv.visitEnd();
+
+ cw.visitEnd();
+ return jarWithClass("fixture/Color", cw.toByteArray());
+ }
+
public static void writeJar(File jar, Map entries) throws IOException {
try (ZipOutputStream out = new ZipOutputStream(new FileOutputStream(jar))) {
for (Map.Entry entry : entries.entrySet()) {
diff --git a/src/test/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/NormalizerLifecycleTest.java b/src/test/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/NormalizerLifecycleTest.java
index a19a823e..8de9b471 100644
--- a/src/test/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/NormalizerLifecycleTest.java
+++ b/src/test/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/NormalizerLifecycleTest.java
@@ -127,6 +127,81 @@ public void testClassMappingFileHonored() throws Throwable {
"fixture/Class0", readClass(out, "fixture/Class0.class").name);
}
+ @Test
+ public void testMethodAsciiKeep() throws Throwable {
+ File in = writeJar("in.jar", TestJarBuilder.fixtureMethodAscii());
+
+ File outKeep = new File(tempFolder.newFolder(), "out-keep.jar");
+ MethodNormalizer.Config c = new MethodNormalizer.Config();
+ c.setKeepAsciiNames(true);
+ run(in, outKeep, c);
+ assertEquals("keep-ascii: ASCII method names untouched, unicode renamed",
+ Arrays.asList("", "premain", "values", "Method0", "Method1"),
+ methodNames(outKeep, "fixture/A.class"));
+
+ File outAll = new File(tempFolder.newFolder(), "out-all.jar");
+ c = new MethodNormalizer.Config();
+ c.setKeepAsciiNames(false);
+ run(in, outAll, c);
+ assertEquals("keep-ascii off: all methods renamed",
+ Arrays.asList("", "Method0", "Method1", "Method2", "Method3"),
+ methodNames(outAll, "fixture/A.class"));
+ }
+
+ @Test
+ public void testFieldAsciiKeep() throws Throwable {
+ File in = writeJar("in.jar", TestJarBuilder.fixtureB());
+
+ File outKeep = new File(tempFolder.newFolder(), "out-keep.jar");
+ FieldNormalizer.Config c = new FieldNormalizer.Config();
+ c.setKeepAsciiNames(true);
+ run(in, outKeep, c);
+ assertEquals("keep-ascii: ASCII field name untouched, unicode renamed",
+ Arrays.asList("count", "Field0"),
+ fieldNames(outKeep, "fixture/B.class"));
+
+ File outAll = new File(tempFolder.newFolder(), "out-all.jar");
+ c = new FieldNormalizer.Config();
+ c.setKeepAsciiNames(false);
+ run(in, outAll, c);
+ assertEquals("keep-ascii off: all fields renamed",
+ Arrays.asList("Field0", "Field1"),
+ fieldNames(outAll, "fixture/B.class"));
+ }
+
+ @Test
+ public void testClassAsciiKeep() throws Throwable {
+ File in = writeJar("in.jar", TestJarBuilder.fixtureKeepClasses());
+
+ File outKeep = new File(tempFolder.newFolder(), "out-keep.jar");
+ ClassNormalizer.Config c = new ClassNormalizer.Config();
+ c.setKeepAsciiNames(true);
+ run(in, outKeep, c);
+ assertEquals("fixture/Keep", readClass(outKeep, "fixture/Keep.class").name);
+ assertEquals("fixture/Class0", readClass(outKeep, "fixture/Class0.class").name);
+ }
+
+ @Test
+ public void testEnumAsciiKeep() throws Throwable {
+ File in = writeJar("in.jar", TestJarBuilder.fixtureColor());
+
+ File outKeep = new File(tempFolder.newFolder(), "out-keep.jar");
+ EnumNormalizer.Config c = new EnumNormalizer.Config();
+ c.setKeepAsciiNames(true);
+ run(in, outKeep, c);
+ assertEquals("keep-ascii: ASCII constant keeps current name, unicode restored from LDC",
+ Arrays.asList("x", "II"),
+ fieldNames(outKeep, "fixture/Color.class"));
+
+ File outAll = new File(tempFolder.newFolder(), "out-all.jar");
+ c = new EnumNormalizer.Config();
+ c.setKeepAsciiNames(false);
+ run(in, outAll, c);
+ assertEquals("keep-ascii off: constants restored from LDC (existing behavior)",
+ Arrays.asList("REALNAME", "II"),
+ fieldNames(outAll, "fixture/Color.class"));
+ }
+
@Test
public void testFieldConsumeStable() throws Throwable {
File in1 = writeJar("in.jar", TestJarBuilder.fixtureB());