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/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> 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/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> 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..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,12 +21,21 @@ 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); classNodes().forEach(classNode -> { - + + if (remapper.mappingExists(classNode.name)) { + return; + } + + if (getConfig().isKeepAsciiNames() + && containsAscii(classNode.name.substring(classNode.name.lastIndexOf('/') + 1))) { + return; + } + String newName = "Class"; if(classNode.name.contains("/")){ @@ -42,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/CustomRemapper.java b/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/CustomRemapper.java index 520c20b4..bea38cfb 100644 --- a/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/CustomRemapper.java +++ b/src/main/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/CustomRemapper.java @@ -18,67 +18,52 @@ 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 { - /** - * 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 +73,266 @@ 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 packageMappingExists(String old) { + return packageMap.contains(old); + } + + 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 boolean mappingExists(String old) { + return classMap.contains(old); + } + + 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 + * 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). + */ + boolean put(String old, String newName) { + if (reversed.containsKey(newName)) { + return false; + } + forward.put(old, newName); + reversed.put(newName, old); return true; } - return false; + + boolean contains(String old) { + return forward.containsKey(old); + } + + 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); + } } - 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; + } + + String get(String name, String desc) { + MemberEntry entry = forward.get(name + desc); + return entry == null ? null : entry.to; + } - private Map packageMap = new HashMap<>(); - private Map packageMapReversed = new HashMap<>(); + boolean exists(String name, String desc) { + return forward.containsKey(name + desc); + } - public boolean map(String old, String newName) { - if (mapReversed.containsKey(newName)) { - return false; + 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(); } - 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<>(); - public String unmap(String ref) { - return mapReversed.get(ref) == null ? ref : mapReversed.get(ref); + MemberStore(CustomRemapper remapper) { + this.remapper = remapper; + } + + 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)); + } + + 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 void setIgnorePackages(boolean ignorePackages) - { - this.ignorePackages = ignorePackages; + + public static final class MemberEntry { + public String name; + public String desc; + public String to; + } + + public static final class MappingFile { + public Map classes; + public Map packages; + public Map> methods; + public Map> fields; } } 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/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 5c068712..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) { @@ -32,17 +32,36 @@ 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"); } }); } - public static class Config extends AbstractNormalizer.Config { + 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(); + } while (!remapper.mapPackage(oldName, mappedName)); + return true; + } + + public static class Config extends AbstractAsciiKeepingNormalizer.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..533e1b0e --- /dev/null +++ b/src/test/java/com/javadeobfuscator/deobfuscator/TestJarBuilder.java @@ -0,0 +1,290 @@ +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 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()) { + 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 new file mode 100644 index 00000000..275df86f --- /dev/null +++ b/src/test/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/CustomRemapperConsolidationTest.java @@ -0,0 +1,128 @@ +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 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")); + } +} 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 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 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..8de9b471 --- /dev/null +++ b/src/test/java/com/javadeobfuscator/deobfuscator/transformers/normalizer/NormalizerLifecycleTest.java @@ -0,0 +1,293 @@ +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 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()); + 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); + } +}