From b9548a73033cd637ead13dc5d7495208892fe7c1 Mon Sep 17 00:00:00 2001 From: Reece Dunham Date: Mon, 24 Aug 2026 19:13:48 -0400 Subject: [PATCH 1/2] Repair inner class nesting when stripped by an obfuscator There's a lot going on here, so I'm going to try and break it down as simply as I can: - Enigma has two concepts of how nesting works which disagree with each other. (1) It grabbed it from the `$` in a class name, but (2) decompilers get it from the InnerClasses attribute. - Some obfuscators (like the one used on Charles Proxy, not sure which it actually is) strip out this nesting attribute, which lead to phantom classes that Enigma could tell were nested, but the decompiler couldn't. - This meant you couldn't edit them! (Or even see them in the GUI's source tree) So, how have I gone about fixing this? Glad you asked! Class trees, class search, `Gui.showReference`, `moveClassTree`, javadoc/stat invalidation (and many more) are now all aware of this and can handle appropriately. Most importantly, we stop silently dropping `$` classes. A lovely new test has been added to prevent it from regressing (I hope) --- .../main/java/org/quiltmc/enigma/gui/Gui.java | 3 +- .../org/quiltmc/enigma/gui/GuiController.java | 16 +- .../enigma/gui/dialog/SearchDialog.java | 2 +- .../gui/node/ClassSelectorClassNode.java | 2 +- .../enigma/gui/panel/EntryTooltip.java | 2 +- .../org/quiltmc/enigma/api/EnigmaProject.java | 12 +- .../analysis/index/jar/InnerClassIndex.java | 61 ++++++++ .../api/analysis/index/jar/JarIndexer.java | 8 + .../api/analysis/index/jar/MainJarIndex.java | 3 +- .../api/class_handle/ClassHandleProvider.java | 3 +- .../enigma/api/stats/StatsGenerator.java | 19 ++- .../impl/analysis/index/AbstractJarIndex.java | 5 + .../analysis/index/IndexClassVisitor.java | 7 + .../vineflower/EnigmaContextSource.java | 25 +++- .../vineflower/EnigmaTextTokenCollector.java | 85 ++++++----- .../source/vineflower/VineflowerSource.java | 50 ++++--- .../java/org/quiltmc/enigma/util/AsmUtil.java | 8 + enigma/src/main/resources/lang/en_us.json | 1 + .../quiltmc/enigma/TestFlatInnerClasses.java | 138 ++++++++++++++++++ 19 files changed, 380 insertions(+), 70 deletions(-) create mode 100644 enigma/src/main/java/org/quiltmc/enigma/api/analysis/index/jar/InnerClassIndex.java create mode 100644 enigma/src/test/java/org/quiltmc/enigma/TestFlatInnerClasses.java diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/Gui.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/Gui.java index 91a69d00a..66fce5d1f 100644 --- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/Gui.java +++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/Gui.java @@ -367,7 +367,8 @@ public void closeEditor(EditorPanel editor) { * @param reference the reference */ public void showReference(EntryReference, Entry> reference) { - this.editorTabbedPane.openClass(reference.getLocationClassEntry().getOutermostClass()).showReference(reference); + ClassEntry sourceRoot = this.controller.getProject().getSourceRoot(reference.getLocationClassEntry()); + this.editorTabbedPane.openClass(sourceRoot).showReference(reference); } public void setObfClasses(Collection obfClasses) { diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/GuiController.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/GuiController.java index ddc724c42..69af71055 100644 --- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/GuiController.java +++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/GuiController.java @@ -444,6 +444,10 @@ public void navigateTo(EntryReference, Entry> reference) { this.openReference(reference); } + public ClassEntry getSourceRoot(Entry entry) { + return this.project.getSourceRoot(entry.getContainingClass()); + } + public void refreshClasses() { if (this.project == null) { return; @@ -461,7 +465,7 @@ public void addSeparatedClasses(List obfClasses, List de Collection classes = this.project.getJarIndex().getIndex(EntryIndex.class).getClasses(); Stream visibleClasses = classes.stream() - .filter(entry -> !entry.isInnerClass()); + .filter(entry -> !this.project.isNestedInSource(entry)); visibleClasses.forEach(entry -> { TranslateResult result = mapper.extendedDeobfuscate(entry); @@ -594,27 +598,27 @@ private void applyChange0(ValidationContext vc, EntryChange change, boolean u // local variable entries need to be propagated up the tree to update param names in javadoc if (target instanceof LocalVariableEntry) { - this.chp.invalidateJavadoc(target.getTopLevelClass()); + this.chp.invalidateJavadoc(this.getSourceRoot(target)); var children = this.project.getJarIndex().getIndex(InheritanceIndex.class).getChildren(target.getContainingClass()); for (ClassEntry child : children) { - this.chp.invalidateJavadoc(child.getTopLevelClass()); + this.chp.invalidateJavadoc(this.getSourceRoot(child)); } } } if (!Objects.equals(prev.javadoc(), mapping.javadoc())) { - this.chp.invalidateJavadoc(target.getTopLevelClass()); + this.chp.invalidateJavadoc(this.getSourceRoot(target)); } - if (renamed && target instanceof ClassEntry classEntry && !classEntry.isInnerClass()) { + if (renamed && target instanceof ClassEntry classEntry && !this.project.isNestedInSource(classEntry)) { boolean isOldOb = prev.targetName() == null; boolean isNewOb = mapping.targetName() == null; this.gui.moveClassTree(target.getContainingClass(), updateSwingState, isOldOb, isNewOb); } else if (updateSwingState) { // update stat icons for classes that could have had their mappings changed by this update boolean propagate = target instanceof FieldEntry || target instanceof MethodEntry || target instanceof LocalVariableEntry; - this.gui.reloadStats(change.getTarget().getTopLevelClass(), propagate); + this.gui.reloadStats(this.getSourceRoot(change.getTarget()), propagate); } } } diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/dialog/SearchDialog.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/dialog/SearchDialog.java index 1af17c204..7eab7bd5a 100644 --- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/dialog/SearchDialog.java +++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/dialog/SearchDialog.java @@ -229,7 +229,7 @@ public void show(boolean clear, Type... types) { switch (searchedType) { case CLASS -> entryIndex.getClasses().parallelStream() - .filter(e -> !e.isInnerClass()) + .filter(e -> !this.gui.getController().getProject().isNestedInSource(e)) .map(e -> SearchEntryImpl.from(e, this.gui.getController())) .map(SearchUtil.Entry::from) .sequential() diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/node/ClassSelectorClassNode.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/node/ClassSelectorClassNode.java index 3caccdf52..e6ed17201 100644 --- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/node/ClassSelectorClassNode.java +++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/node/ClassSelectorClassNode.java @@ -122,7 +122,7 @@ public void done() { @Override public String toString() { - return this.deobfEntry.getSimpleName(); + return this.deobfEntry.getContextualName(); } @Override diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/panel/EntryTooltip.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/panel/EntryTooltip.java index dd19f4843..b375d2738 100644 --- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/panel/EntryTooltip.java +++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/panel/EntryTooltip.java @@ -319,7 +319,7 @@ public void mousePressed(MouseEvent e) { { final ClassHandle targetTopClassHandle = this.gui.getController().getClassHandleProvider() - .openClass(target.getTopLevelClass()); + .openClass(this.gui.getController().getSourceRoot(target)); if (targetTopClassHandle != null) { this.declarationSnippet = new DeclarationSnippetPanel(this.gui, target, targetTopClassHandle); diff --git a/enigma/src/main/java/org/quiltmc/enigma/api/EnigmaProject.java b/enigma/src/main/java/org/quiltmc/enigma/api/EnigmaProject.java index 41473e185..037e3ed8d 100644 --- a/enigma/src/main/java/org/quiltmc/enigma/api/EnigmaProject.java +++ b/enigma/src/main/java/org/quiltmc/enigma/api/EnigmaProject.java @@ -8,6 +8,7 @@ import org.quiltmc.enigma.api.analysis.EntryReference; import org.quiltmc.enigma.api.analysis.index.jar.EnclosingMethodIndex; import org.quiltmc.enigma.api.analysis.index.jar.EntryIndex; +import org.quiltmc.enigma.api.analysis.index.jar.InnerClassIndex; import org.quiltmc.enigma.api.analysis.index.jar.JarIndex; import org.quiltmc.enigma.api.analysis.index.mapping.MappingsIndex; import org.quiltmc.enigma.api.service.ObfuscationTestService; @@ -33,6 +34,7 @@ import org.quiltmc.enigma.api.translation.representation.entry.LocalVariableEntry; import org.quiltmc.enigma.api.translation.representation.entry.MethodEntry; import org.quiltmc.enigma.impl.translation.mapping.MappingsChecker; +import org.quiltmc.enigma.util.AsmUtil; import org.quiltmc.enigma.util.I18n; import org.tinylog.Logger; @@ -306,6 +308,14 @@ public boolean isAnonymousOrLocal(ClassEntry classEntry) { return enclosingMethodIndex.hasEnclosingMethod(classEntry); } + public boolean isNestedInSource(ClassEntry classEntry) { + return this.jarIndex.getIndex(InnerClassIndex.class).isNestedInSource(classEntry); + } + + public ClassEntry getSourceRoot(ClassEntry classEntry) { + return this.jarIndex.getIndex(InnerClassIndex.class).getSourceRoot(classEntry); + } + /** * Verifies that the provided {@code parameter} has a valid index for its parent method. * This method validates both the upper and lower bounds of the parent method's index range. @@ -413,7 +423,7 @@ public SourceExport decompile(ProgressListener progress, DecompilerService decom public Stream decompileStream(ProgressListener progress, DecompilerService decompilerService, DecompileErrorStrategy errorStrategy) { Collection classes = this.compiled.values().stream() - .filter(classNode -> classNode.name.indexOf('$') == -1) + .filter(classNode -> !AsmUtil.isNestedInSource(classNode)) .toList(); progress.init(classes.size(), I18n.translate("progress.classes.decompiling")); diff --git a/enigma/src/main/java/org/quiltmc/enigma/api/analysis/index/jar/InnerClassIndex.java b/enigma/src/main/java/org/quiltmc/enigma/api/analysis/index/jar/InnerClassIndex.java new file mode 100644 index 000000000..10ab256e3 --- /dev/null +++ b/enigma/src/main/java/org/quiltmc/enigma/api/analysis/index/jar/InnerClassIndex.java @@ -0,0 +1,61 @@ +package org.quiltmc.enigma.api.analysis.index.jar; + +import org.jspecify.annotations.NonNull; +import org.quiltmc.enigma.api.translation.representation.entry.ClassDefEntry; +import org.quiltmc.enigma.api.translation.representation.entry.ClassEntry; + +import java.util.HashSet; +import java.util.Set; + +/** + * An index of the classes a jar declares nested with the {@code InnerClasses} attribute. + * + *

Enigma takes nesting from class names, where {@code a/B$C} is an inner class of {@code a/B}. + * Decompilers take it from the {@code InnerClasses} attribute, and only put a class' source inside another + * class' source when that attribute says so. An obfuscator that strips the attribute without renaming makes + * the two disagree: the class still reads as nested, but it decompiles to a file of its own. + * + *

A class counts as {@linkplain #isNestedInSource nested in source} when any class in the jar declares + * an {@code InnerClasses} record naming it. The compiler writes that record in the nested class, in its + * enclosing class and in every class that references it, so one record anywhere in the jar is enough. + */ +public class InnerClassIndex implements JarIndexer { + private final Set nestedInSource = new HashSet<>(); + + @Override + public void indexInnerClass(ClassDefEntry classEntry, @NonNull InnerClassData innerClassData) { + this.nestedInSource.add(new ClassEntry(innerClassData.name())); + } + + /** + * Returns whether {@code entry}'s source is written inside another class' source, i.e. whether the jar + * declares it nested with an {@code InnerClasses} record. A class whose name looks nested but that has no + * such record decompiles to a file of its own, and this returns {@code false} for it. + * + * @param entry the class to check + */ + public boolean isNestedInSource(ClassEntry entry) { + return this.nestedInSource.contains(entry); + } + + /** + * Returns the class whose source contains {@code entry}'s, which is {@code entry} itself unless it is + * {@linkplain #isNestedInSource nested in source}. This is the class to decompile, to open in an editor + * and to index tokens against when navigating to {@code entry}. + * + * @param entry the class to find the source of + */ + public ClassEntry getSourceRoot(ClassEntry entry) { + ClassEntry root = entry; + while (root.getOuterClass() != null && this.isNestedInSource(root)) { + root = root.getOuterClass(); + } + + return root; + } + + @Override + public String getTranslationKey() { + return "progress.jar.indexing.process.inner_classes"; + } +} diff --git a/enigma/src/main/java/org/quiltmc/enigma/api/analysis/index/jar/JarIndexer.java b/enigma/src/main/java/org/quiltmc/enigma/api/analysis/index/jar/JarIndexer.java index ce8e73710..5d64f0c81 100644 --- a/enigma/src/main/java/org/quiltmc/enigma/api/analysis/index/jar/JarIndexer.java +++ b/enigma/src/main/java/org/quiltmc/enigma/api/analysis/index/jar/JarIndexer.java @@ -1,5 +1,6 @@ package org.quiltmc.enigma.api.analysis.index.jar; +import org.jspecify.annotations.Nullable; import org.quiltmc.enigma.api.analysis.ReferenceTargetType; import org.quiltmc.enigma.api.translation.representation.Lambda; import org.quiltmc.enigma.api.translation.representation.entry.ClassDefEntry; @@ -34,6 +35,9 @@ default void indexLambda(MethodDefEntry callerEntry, Lambda lambda, ReferenceTar default void indexEnclosingMethod(ClassDefEntry classEntry, EnclosingMethodData enclosingMethodData) { } + default void indexInnerClass(ClassDefEntry classEntry, InnerClassData innerClassData) { + } + default void processIndex(JarIndex index) { } @@ -51,6 +55,10 @@ default Class getType() { return this.getClass(); } + // https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-4.html#jvms-4.7.6 + record InnerClassData(String name, @Nullable String outerName, @Nullable String innerName, int access) { + } + record EnclosingMethodData(String owner, String name, String descriptor) { public MethodEntry getMethod() { return MethodEntry.parse(this.owner, this.name, this.descriptor); diff --git a/enigma/src/main/java/org/quiltmc/enigma/api/analysis/index/jar/MainJarIndex.java b/enigma/src/main/java/org/quiltmc/enigma/api/analysis/index/jar/MainJarIndex.java index 95fb08eef..034d6060c 100644 --- a/enigma/src/main/java/org/quiltmc/enigma/api/analysis/index/jar/MainJarIndex.java +++ b/enigma/src/main/java/org/quiltmc/enigma/api/analysis/index/jar/MainJarIndex.java @@ -27,10 +27,11 @@ public static MainJarIndex empty() { BridgeMethodIndex bridgeMethodIndex = new IndependentBridgeMethodIndex(entryIndex, inheritanceIndex, referenceIndex); PackageVisibilityIndex packageVisibilityIndex = new PackageVisibilityIndex(); EnclosingMethodIndex enclosingMethodIndex = new EnclosingMethodIndex(); + InnerClassIndex innerClassIndex = new InnerClassIndex(); LambdaIndex lambdaIndex = new LambdaIndex(); return new MainJarIndex( entryIndex, inheritanceIndex, referenceIndex, bridgeMethodIndex, - packageVisibilityIndex, enclosingMethodIndex, lambdaIndex + packageVisibilityIndex, enclosingMethodIndex, innerClassIndex, lambdaIndex ); } diff --git a/enigma/src/main/java/org/quiltmc/enigma/api/class_handle/ClassHandleProvider.java b/enigma/src/main/java/org/quiltmc/enigma/api/class_handle/ClassHandleProvider.java index 4a475856a..d12204cac 100644 --- a/enigma/src/main/java/org/quiltmc/enigma/api/class_handle/ClassHandleProvider.java +++ b/enigma/src/main/java/org/quiltmc/enigma/api/class_handle/ClassHandleProvider.java @@ -143,7 +143,8 @@ public void invalidateJavadoc(ClassEntry entry) { e.invalidateJavadoc(); } - if (entry.isInnerClass()) { + // only a class written inside another class' source affects that class' text + if (entry.isInnerClass() && this.project.isNestedInSource(entry)) { this.invalidateJavadoc(entry.getOuterClass()); } }); diff --git a/enigma/src/main/java/org/quiltmc/enigma/api/stats/StatsGenerator.java b/enigma/src/main/java/org/quiltmc/enigma/api/stats/StatsGenerator.java index dd854b633..c6a423344 100644 --- a/enigma/src/main/java/org/quiltmc/enigma/api/stats/StatsGenerator.java +++ b/enigma/src/main/java/org/quiltmc/enigma/api/stats/StatsGenerator.java @@ -128,7 +128,7 @@ public ProjectStatsResult generate(ProgressListener progress, @Nullable ClassEnt this.generationLatch = new CountDownLatch(1); List classes = this.entryIndex.getClasses() - .stream().filter(entry -> !entry.isInnerClass()).toList(); + .stream().filter(entry -> !this.project.isNestedInSource(entry)).toList(); int done = 0; progress.init(classes.size() - 1, I18n.translate("progress.stats")); @@ -162,7 +162,7 @@ public ProjectStatsResult generate(ProgressListener progress, @Nullable ClassEnt private void addChildrenRecursively(List> entries, Entry toCheck) { if (toCheck instanceof ClassEntry innerClassEntry) { - List> classChildren = this.project.getJarIndex().getChildrenByClass().get(innerClassEntry); + List> classChildren = this.ownChildren(innerClassEntry); if (!classChildren.isEmpty()) { entries.addAll(classChildren); for (Entry entry : classChildren) { @@ -174,6 +174,16 @@ private void addChildrenRecursively(List> entries, Entry toCheck) { } } + /** + * Returns the members of {@code classEntry} and the classes written inside its source. A class that is + * not nested in source has its own stats, so including it here would count its members twice. + */ + private List> ownChildren(ClassEntry classEntry) { + return this.project.getJarIndex().getChildrenByClass().get(classEntry).stream() + .filter(child -> !(child instanceof ClassEntry childClass) || this.project.isNestedInSource(childClass)) + .toList(); + } + /** * Generates stats for the provided class. * @param classEntry the class to generate stats for @@ -192,7 +202,7 @@ private StatsResult generate(ClassEntry classEntry, GenerationParameters paramet Map mappableCounts = new EnumMap<>(StatType.class); Map> unmappedCounts = new EnumMap<>(StatType.class); - List> children = this.project.getJarIndex().getChildrenByClass().get(classEntry); + List> children = this.ownChildren(classEntry); List> entries = new ArrayList<>(children); for (Entry entry : children) { @@ -311,7 +321,8 @@ private void update(StatType type, Map mappable, Map new HashMap<>()); unmapped.get(type).put(parent, unmapped.get(type).getOrDefault(parent, 0) + 1); diff --git a/enigma/src/main/java/org/quiltmc/enigma/impl/analysis/index/AbstractJarIndex.java b/enigma/src/main/java/org/quiltmc/enigma/impl/analysis/index/AbstractJarIndex.java index 2db12a060..aa85be153 100644 --- a/enigma/src/main/java/org/quiltmc/enigma/impl/analysis/index/AbstractJarIndex.java +++ b/enigma/src/main/java/org/quiltmc/enigma/impl/analysis/index/AbstractJarIndex.java @@ -228,6 +228,11 @@ public void indexLambda(MethodDefEntry callerEntry, Lambda lambda, ReferenceTarg this.indexers.forEach((key, indexer) -> indexer.indexLambda(callerEntry, lambda, targetType)); } + @Override + public void indexInnerClass(ClassDefEntry classEntry, InnerClassData innerClassData) { + this.indexers.forEach((key, indexer) -> indexer.indexInnerClass(classEntry, innerClassData)); + } + @Override public void indexEnclosingMethod(ClassDefEntry classEntry, EnclosingMethodData enclosingMethodData) { this.indexers.forEach((key, indexer) -> indexer.indexEnclosingMethod(classEntry, enclosingMethodData)); diff --git a/enigma/src/main/java/org/quiltmc/enigma/impl/analysis/index/IndexClassVisitor.java b/enigma/src/main/java/org/quiltmc/enigma/impl/analysis/index/IndexClassVisitor.java index eea0e0fcf..52f222441 100644 --- a/enigma/src/main/java/org/quiltmc/enigma/impl/analysis/index/IndexClassVisitor.java +++ b/enigma/src/main/java/org/quiltmc/enigma/impl/analysis/index/IndexClassVisitor.java @@ -35,6 +35,13 @@ public void visitOuterClass(String owner, String name, String descriptor) { super.visitOuterClass(owner, name, descriptor); } + @Override + public void visitInnerClass(String name, String outerName, String innerName, int access) { + this.indexer.indexInnerClass(this.classEntry, new JarIndexer.InnerClassData(name, outerName, innerName, access)); + + super.visitInnerClass(name, outerName, innerName, access); + } + @Override public FieldVisitor visitField(int access, String name, String desc, String signature, Object value) { this.indexer.indexField(FieldDefEntry.parse(this.classEntry, access, name, desc, signature)); diff --git a/enigma/src/main/java/org/quiltmc/enigma/impl/source/vineflower/EnigmaContextSource.java b/enigma/src/main/java/org/quiltmc/enigma/impl/source/vineflower/EnigmaContextSource.java index daa544595..2b68fe061 100644 --- a/enigma/src/main/java/org/quiltmc/enigma/impl/source/vineflower/EnigmaContextSource.java +++ b/enigma/src/main/java/org/quiltmc/enigma/impl/source/vineflower/EnigmaContextSource.java @@ -29,6 +29,11 @@ public IContextSource getExternalSource() { return this.external; } + /** The internal name of the class this source was created for. */ + public String getClassName() { + return this.name; + } + @Override public String getName() { return "class " + this.name; @@ -40,14 +45,30 @@ private void collectClassNames() { } this.classNames = new ArrayList<>(); - String root = this.name.contains("$") ? this.name.substring(0, this.name.indexOf("$")) : this.name; + String root = this.sourceRoot(this.name); this.classNames.add(root); Map options = VineflowerPreferences.getEffectiveOptions(); if (!options.containsKey(IFernflowerPreferences.DECOMPILE_INNER) || "1".equals(options.get(IFernflowerPreferences.DECOMPILE_INNER))) { - this.classNames.addAll(this.classProvider.getClasses(root).stream().filter(s -> s.contains("$")).toList()); + this.classNames.addAll(this.classProvider.getClasses(root).stream() + .filter(s -> !s.equals(root) && this.isNestedInSource(s)) + .toList()); + } + } + + private String sourceRoot(String className) { + String root = className; + while (root.lastIndexOf('$') > 0 && this.isNestedInSource(root)) { + root = root.substring(0, root.lastIndexOf('$')); } + + return root; + } + + private boolean isNestedInSource(String className) { + ClassNode node = this.classProvider.get(className); + return node != null && AsmUtil.isNestedInSource(node); } @Override diff --git a/enigma/src/main/java/org/quiltmc/enigma/impl/source/vineflower/EnigmaTextTokenCollector.java b/enigma/src/main/java/org/quiltmc/enigma/impl/source/vineflower/EnigmaTextTokenCollector.java index 25b48e38f..7fca891eb 100644 --- a/enigma/src/main/java/org/quiltmc/enigma/impl/source/vineflower/EnigmaTextTokenCollector.java +++ b/enigma/src/main/java/org/quiltmc/enigma/impl/source/vineflower/EnigmaTextTokenCollector.java @@ -57,10 +57,18 @@ public class EnigmaTextTokenCollector extends TextTokenVisitor { private final Deque classStack = new ArrayDeque<>(); private final Deque methodStack = new ArrayDeque<>(); - private final Map> declarations = new HashMap<>(); - private final Map, Entry>> references = new HashMap<>(); - private final Map tokens = new LinkedHashMap<>(); + /** + * Tokens collected for each class file written during this decompile run, keyed by the class the file + * declares at its top level. + * + *

Every file Vineflower writes is passed through a collector, but each file's token ranges are + * relative to that file's own text. To prevent clashing with siblings, we isolate each file. + */ + private final Map tokensByClass = new LinkedHashMap<>(); + private ContentTokens current = new ContentTokens(); + private boolean currentIsKeyed; private final Map classRanges = new HashMap<>(); + private final Map> classDeclarations = new HashMap<>(); private final List syntheticMethods = new ArrayList<>(); private final Deque openSynthetic = new ArrayDeque<>(); private final Map syntheticEntryBySpan = new HashMap<>(); @@ -94,26 +102,35 @@ private Token getToken(TextRange range) { } private void addDeclaration(Token token, Entry entry) { - this.declarations.put(token, entry); - this.tokens.put(token, true); + this.current.declarations.put(token, entry); + this.current.tokens.put(token, true); } private void addReference(Token token, Entry entry, Entry context) { - this.references.put(token, Pair.of(entry, context)); - this.tokens.put(token, false); + this.current.references.put(token, Pair.of(entry, context)); + this.current.tokens.put(token, false); } - public void addTokensToIndex(SourceIndex index, UnaryOperator tokenProcessor) { - for (Token token : this.tokens.keySet()) { + public boolean hasTokensFor(String className) { + return this.tokensByClass.containsKey(className); + } + + public void addTokensToIndex(SourceIndex index, String className, UnaryOperator tokenProcessor) { + ContentTokens collected = this.tokensByClass.get(className); + if (collected == null) { + return; + } + + for (Token token : collected.tokens.keySet()) { Token newToken = tokenProcessor.apply(token); if (newToken == null) { continue; } - if (this.tokens.get(token)) { - index.addDeclaration(newToken, this.declarations.get(token)); + if (collected.tokens.get(token)) { + index.addDeclaration(newToken, collected.declarations.get(token)); } else { - Pair, Entry> ref = this.references.get(token); + Pair, Entry> ref = collected.references.get(token); index.addReference(newToken, ref.a, ref.b); } } @@ -155,27 +172,9 @@ private void parseSource() { this.addClassAndChildren(decl, pkgPrefix + decl.getNameAsString()); } - for (ClassEntry classEntry : this.classRanges.keySet()) { - String[] parts = classEntry.getContextualName().split("\\$"); - TypeDeclaration type = null; - for (TypeDeclaration decl : unit.getTypes()) { - if (decl.getNameAsString().equals(parts[0])) { - type = decl; - break; - } - } - - for (int i = 1; i < parts.length; i++) { - if (type != null) { - TypeDeclaration finalType = type; - String name = parts[i]; - type = type.findFirst(TypeDeclaration.class, t -> t != finalType && t.getNameAsString().equals(name)).orElse(null); - } - } - - if (type == null) { - throw new IllegalStateException("Could not find type " + classEntry.getContextualName() + " in parsed source"); - } + for (Map.Entry> classDeclaration : this.classDeclarations.entrySet()) { + ClassEntry classEntry = classDeclaration.getKey(); + TypeDeclaration type = classDeclaration.getValue(); Map rootNodes = new HashMap<>(); Map seenMethods = new HashMap<>(); @@ -350,7 +349,9 @@ private void addClassAndChildren(TypeDeclaration decl, String name) { return; } - this.classRanges.put(getClassEntry(name), textRange); + ClassEntry entry = getClassEntry(name); + this.classRanges.put(entry, textRange); + this.classDeclarations.put(entry, decl); decl.getMembers().forEach(member -> { if (member instanceof TypeDeclaration child) { this.addClassAndChildren(child, name + "$" + child.getNameAsString()); @@ -425,7 +426,11 @@ private MethodEntry getSyntheticMethodEntry(SyntheticMethodSpan method) { public void start(String content) { this.content = content; this.lineIndexer = new LineIndexer(content); + this.current = new ContentTokens(); + this.currentIsKeyed = false; this.classRanges.clear(); + this.classDeclarations.clear(); + this.classStack.clear(); this.methodStack.clear(); this.openSynthetic.clear(); this.syntheticMethods.clear(); @@ -441,6 +446,12 @@ public void visitClass(TextRange range, boolean declaration, String name) { this.updateMethodStack(range); if (declaration) { + if (!this.currentIsKeyed) { + // the first declaration in a file is its top-level class, which is what the file is written for + this.currentIsKeyed = true; + this.tokensByClass.put(name, this.current); + } + this.classStack.push(getClassEntry(name)); this.addDeclaration(token, getClassEntry(name)); } else { @@ -513,6 +524,12 @@ public void visitLocal(TextRange range, boolean declaration, String className, S } } + private static final class ContentTokens { + private final Map> declarations = new HashMap<>(); + private final Map, Entry>> references = new HashMap<>(); + private final Map tokens = new LinkedHashMap<>(); + } + private record SyntheticMethodSpan(TextRange range, boolean isLambda) {} class LambdaNode { diff --git a/enigma/src/main/java/org/quiltmc/enigma/impl/source/vineflower/VineflowerSource.java b/enigma/src/main/java/org/quiltmc/enigma/impl/source/vineflower/VineflowerSource.java index b54d14619..6442d1ecb 100644 --- a/enigma/src/main/java/org/quiltmc/enigma/impl/source/vineflower/VineflowerSource.java +++ b/enigma/src/main/java/org/quiltmc/enigma/impl/source/vineflower/VineflowerSource.java @@ -5,34 +5,38 @@ import org.jetbrains.java.decompiler.main.extern.IContextSource; import org.jetbrains.java.decompiler.main.extern.IFernflowerLogger; import org.jetbrains.java.decompiler.main.extern.IFernflowerPreferences; -import org.jetbrains.java.decompiler.main.extern.IResultSaver; import org.jetbrains.java.decompiler.main.extern.TextTokenVisitor; import org.jspecify.annotations.Nullable; import org.quiltmc.enigma.api.source.Source; import org.quiltmc.enigma.api.source.SourceIndex; import org.quiltmc.enigma.api.source.SourceSettings; import org.quiltmc.enigma.api.translation.mapping.EntryRemapper; +import org.tinylog.Logger; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import java.util.Map; -import java.util.concurrent.atomic.AtomicReference; public class VineflowerSource implements Source { private final IContextSource contextSource; private final IContextSource libraryContextSource; private final boolean hasLibrarySource; + private final String className; private EntryRemapper remapper; private final SourceSettings settings; private SourceIndex index; public VineflowerSource(EnigmaContextSource contextSource, EntryRemapper remapper, SourceSettings settings) { - this(contextSource, contextSource.getExternalSource(), remapper, settings); + this(contextSource, contextSource.getExternalSource(), contextSource.getClassName(), remapper, settings); } - public VineflowerSource(IContextSource contextSource, @Nullable IContextSource libraryContextSource, EntryRemapper remapper, SourceSettings settings) { + public VineflowerSource(IContextSource contextSource, @Nullable IContextSource libraryContextSource, String className, EntryRemapper remapper, SourceSettings settings) { this.contextSource = contextSource; this.libraryContextSource = libraryContextSource; this.hasLibrarySource = libraryContextSource != null; + this.className = className; this.remapper = remapper; this.settings = settings; } @@ -74,37 +78,49 @@ private void checkDecompiled() { this.index = new SourceIndex(); - IResultSaver saver = new EnigmaResultSaver(this.index); + EnigmaResultSaver saver = new EnigmaResultSaver(this.index); Map options = getOptions(new EnigmaJavadocProvider(this.remapper), this.settings); IFernflowerLogger logger = new EnigmaFernflowerLogger(); BaseDecompiler decompiler = new BaseDecompiler(saver, options, logger); - AtomicReference tokenCollector = new AtomicReference<>(); + List tokenCollectors = Collections.synchronizedList(new ArrayList<>()); TextTokenVisitor.addVisitor(next -> { - tokenCollector.set(new EnigmaTextTokenCollector(next)); - return tokenCollector.get(); + EnigmaTextTokenCollector collector = new EnigmaTextTokenCollector(next); + tokenCollectors.add(collector); + return collector; }); decompiler.addSource(this.contextSource); if (this.hasLibrarySource) decompiler.addLibrary(this.libraryContextSource); decompiler.decompileContext(); - if (this.settings.removeImports()) { - removePackageStatement(this.index, tokenCollector.get()); - } else { - tokenCollector.get().addTokensToIndex(this.index, token -> token); + EnigmaTextTokenCollector tokenCollector = null; + synchronized (tokenCollectors) { + for (EnigmaTextTokenCollector collector : tokenCollectors) { + if (collector.hasTokensFor(this.className)) { + tokenCollector = collector; + break; + } + } } - } - private static void removePackageStatement(SourceIndex index, EnigmaTextTokenCollector tokenCollector) { if (tokenCollector == null) { - throw new IllegalStateException("No token collector"); + Logger.warn("No tokens were collected for {}", this.className); + return; } + if (this.settings.removeImports()) { + removePackageStatement(this.index, tokenCollector, this.className); + } else { + tokenCollector.addTokensToIndex(this.index, this.className, token -> token); + } + } + + private static void removePackageStatement(SourceIndex index, EnigmaTextTokenCollector tokenCollector, String className) { String source = index.getSource(); int start = source.indexOf("package"); if (start < 0) { - tokenCollector.addTokensToIndex(index, token -> token); + tokenCollector.addTokensToIndex(index, className, token -> token); return; } @@ -113,7 +129,7 @@ private static void removePackageStatement(SourceIndex index, EnigmaTextTokenCol String newSource = source.substring(0, start) + source.substring(end + 1); index.setSource(newSource); - tokenCollector.addTokensToIndex(index, token -> { + tokenCollector.addTokensToIndex(index, className, token -> { if (token.start > end) { return token.move(offset); } else if (token.end <= start) { diff --git a/enigma/src/main/java/org/quiltmc/enigma/util/AsmUtil.java b/enigma/src/main/java/org/quiltmc/enigma/util/AsmUtil.java index 501bf1381..f54b41241 100644 --- a/enigma/src/main/java/org/quiltmc/enigma/util/AsmUtil.java +++ b/enigma/src/main/java/org/quiltmc/enigma/util/AsmUtil.java @@ -17,4 +17,12 @@ public static ClassNode bytesToNode(byte[] bytes) { r.accept(node, 0); return node; } + + public static boolean isNestedInSource(ClassNode node) { + if (node.innerClasses == null) { + return false; + } + + return node.innerClasses.stream().anyMatch(innerClass -> innerClass.name.equals(node.name)); + } } diff --git a/enigma/src/main/resources/lang/en_us.json b/enigma/src/main/resources/lang/en_us.json index d04b5487e..d71ceb871 100644 --- a/enigma/src/main/resources/lang/en_us.json +++ b/enigma/src/main/resources/lang/en_us.json @@ -229,6 +229,7 @@ "progress.jar.indexing.process.entries": "Entries...", "progress.jar.indexing.process.inheritance": "Inheritance...", "progress.jar.indexing.process.enclosing_methods": "Enclosing methods...", + "progress.jar.indexing.process.inner_classes": "Inner classes...", "progress.jar.indexing.process.package_visibility": "Package visibility...", "progress.jar.indexing.process.lambdas": "Lambdas...", "progress.jar.indexing.process.member_types": "Member types...", diff --git a/enigma/src/test/java/org/quiltmc/enigma/TestFlatInnerClasses.java b/enigma/src/test/java/org/quiltmc/enigma/TestFlatInnerClasses.java new file mode 100644 index 000000000..dae49c729 --- /dev/null +++ b/enigma/src/test/java/org/quiltmc/enigma/TestFlatInnerClasses.java @@ -0,0 +1,138 @@ +package org.quiltmc.enigma; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; +import org.quiltmc.enigma.api.ProgressListener; +import org.quiltmc.enigma.api.analysis.index.jar.InnerClassIndex; +import org.quiltmc.enigma.api.analysis.index.jar.JarIndex; +import org.quiltmc.enigma.api.analysis.index.jar.MainJarIndex; +import org.quiltmc.enigma.api.class_provider.CachingClassProvider; +import org.quiltmc.enigma.api.class_provider.ClassProvider; +import org.quiltmc.enigma.api.class_provider.JarClassProvider; +import org.quiltmc.enigma.api.class_provider.ProjectClassProvider; +import org.quiltmc.enigma.api.source.Decompilers; +import org.quiltmc.enigma.api.source.SourceIndex; +import org.quiltmc.enigma.api.source.SourceSettings; +import org.quiltmc.enigma.api.translation.representation.entry.ClassEntry; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; + +public class TestFlatInnerClasses { + private static final ClassEntry OUTER = new ClassEntry("a/Outer"); + private static final ClassEntry INNER = new ClassEntry("a/Outer$Inner"); + + @TempDir + private Path tempDir; + + @Test + public void nestedInSourceWhenDeclared() throws IOException { + JarIndex index = this.index(this.writeJar("declared.jar", true)); + InnerClassIndex innerClasses = index.getIndex(InnerClassIndex.class); + + assertThat(innerClasses.isNestedInSource(INNER), is(true)); + assertThat(innerClasses.getSourceRoot(INNER), is(OUTER)); + assertThat(innerClasses.getSourceRoot(OUTER), is(OUTER)); + } + + @Test + public void notNestedInSourceWithoutTheAttribute() throws IOException { + JarIndex index = this.index(this.writeJar("stripped.jar", false)); + InnerClassIndex innerClasses = index.getIndex(InnerClassIndex.class); + + // the name still reads as nested, and the mappings still treat it as an inner class + assertThat(INNER.isInnerClass(), is(true)); + + assertThat(innerClasses.isNestedInSource(INNER), is(false)); + assertThat(innerClasses.getSourceRoot(INNER), is(INNER)); + } + + @Test + public void declaredInnerClassIsDecompiledIntoItsOuterClass() throws IOException { + SourceIndex source = this.decompile(this.writeJar("declared.jar", true), OUTER); + + assertThat(source.getDeclarationToken(OUTER), is(notNullValue())); + assertThat(source.getDeclarationToken(INNER), is(notNullValue())); + } + + @Test + public void flatInnerClassIsDecompiledOnItsOwn() throws IOException { + Path jar = this.writeJar("stripped.jar", false); + + SourceIndex innerSource = this.decompile(jar, INNER); + assertThat(innerSource.getDeclarationToken(INNER), is(notNullValue())); + + // its tokens can't leak into the class it is named after, whose file does not contain it + SourceIndex outerSource = this.decompile(jar, OUTER); + assertThat(outerSource.getDeclarationToken(OUTER), is(notNullValue())); + assertThat(outerSource.getDeclarationToken(INNER), is(nullValue())); + } + + private SourceIndex decompile(Path jar, ClassEntry entry) throws IOException { + ClassProvider classProvider = new CachingClassProvider(new JarClassProvider(jar)); + return Decompilers.VINEFLOWER.create(classProvider, new SourceSettings(false, false)) + .getUndocumentedSource(entry.getFullName()) + .index(); + } + + private JarIndex index(Path jar) throws IOException { + JarIndex index = MainJarIndex.empty(); + ClassProvider classProvider = new CachingClassProvider(new JarClassProvider(jar)); + index.indexJar(new ProjectClassProvider(classProvider, null), ProgressListener.createEmpty()); + return index; + } + + private Path writeJar(String name, boolean declareNesting) throws IOException { + Path jar = this.tempDir.resolve(name); + if (Files.exists(jar)) { + return jar; + } + + try (JarOutputStream out = new JarOutputStream(Files.newOutputStream(jar))) { + write(out, OUTER, declareNesting); + write(out, INNER, declareNesting); + } + + return jar; + } + + private static void write(JarOutputStream out, ClassEntry entry, boolean declareNesting) throws IOException { + ClassWriter writer = new ClassWriter(0); + writer.visit(Opcodes.V17, Opcodes.ACC_PUBLIC | Opcodes.ACC_SUPER, entry.getFullName(), null, "java/lang/Object", null); + + if (declareNesting) { + writer.visitInnerClass(INNER.getFullName(), OUTER.getFullName(), "Inner", Opcodes.ACC_STATIC); + } + + MethodVisitor init = writer.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null); + init.visitCode(); + init.visitVarInsn(Opcodes.ALOAD, 0); + init.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V", false); + init.visitInsn(Opcodes.RETURN); + init.visitMaxs(1, 1); + init.visitEnd(); + + MethodVisitor method = writer.visitMethod(Opcodes.ACC_PUBLIC, "run", "()V", null, null); + method.visitCode(); + method.visitInsn(Opcodes.RETURN); + method.visitMaxs(0, 1); + method.visitEnd(); + + writer.visitEnd(); + + out.putNextEntry(new JarEntry(entry.getFullName() + ".class")); + out.write(writer.toByteArray()); + out.closeEntry(); + } +} From 4e79ec588dbdc614c5c98c974a463796ef30642c Mon Sep 17 00:00:00 2001 From: Reece Dunham Date: Sun, 30 Aug 2026 22:26:07 -0400 Subject: [PATCH 2/2] CR feedback --- .../analysis/index/jar/InnerClassIndex.java | 6 +- .../vineflower/EnigmaContextSource.java | 11 +- .../source/vineflower/VineflowerSource.java | 12 +-- .../quiltmc/enigma/TestFlatInnerClasses.java | 101 +++++------------- .../input/flat_inner_classes/Outer.java | 14 +++ .../proguard-flat_inner_classes-test.conf | 7 ++ 6 files changed, 61 insertions(+), 90 deletions(-) create mode 100644 enigma/src/test/java/org/quiltmc/enigma/input/flat_inner_classes/Outer.java create mode 100644 enigma/src/test/resources/proguard-flat_inner_classes-test.conf diff --git a/enigma/src/main/java/org/quiltmc/enigma/api/analysis/index/jar/InnerClassIndex.java b/enigma/src/main/java/org/quiltmc/enigma/api/analysis/index/jar/InnerClassIndex.java index 10ab256e3..24bf3e65a 100644 --- a/enigma/src/main/java/org/quiltmc/enigma/api/analysis/index/jar/InnerClassIndex.java +++ b/enigma/src/main/java/org/quiltmc/enigma/api/analysis/index/jar/InnerClassIndex.java @@ -28,9 +28,9 @@ public void indexInnerClass(ClassDefEntry classEntry, @NonNull InnerClassData in } /** - * Returns whether {@code entry}'s source is written inside another class' source, i.e. whether the jar + * Returns whether the passed {@code entry}'s source is written inside another class' source, i.e. whether the jar * declares it nested with an {@code InnerClasses} record. A class whose name looks nested but that has no - * such record decompiles to a file of its own, and this returns {@code false} for it. + * such record yields {@code false}. * * @param entry the class to check */ @@ -39,7 +39,7 @@ public boolean isNestedInSource(ClassEntry entry) { } /** - * Returns the class whose source contains {@code entry}'s, which is {@code entry} itself unless it is + * Returns the class whose source contains the passed {@code entry}'s, which is {@code entry} itself unless it is * {@linkplain #isNestedInSource nested in source}. This is the class to decompile, to open in an editor * and to index tokens against when navigating to {@code entry}. * diff --git a/enigma/src/main/java/org/quiltmc/enigma/impl/source/vineflower/EnigmaContextSource.java b/enigma/src/main/java/org/quiltmc/enigma/impl/source/vineflower/EnigmaContextSource.java index 2b68fe061..b085e1df1 100644 --- a/enigma/src/main/java/org/quiltmc/enigma/impl/source/vineflower/EnigmaContextSource.java +++ b/enigma/src/main/java/org/quiltmc/enigma/impl/source/vineflower/EnigmaContextSource.java @@ -45,7 +45,7 @@ private void collectClassNames() { } this.classNames = new ArrayList<>(); - String root = this.sourceRoot(this.name); + String root = this.getSourceRoot(this.name); this.classNames.add(root); Map options = VineflowerPreferences.getEffectiveOptions(); @@ -57,10 +57,13 @@ private void collectClassNames() { } } - private String sourceRoot(String className) { + private String getSourceRoot(String className) { String root = className; - while (root.lastIndexOf('$') > 0 && this.isNestedInSource(root)) { - root = root.substring(0, root.lastIndexOf('$')); + int separator = root.lastIndexOf('$'); + + while (separator > 0 && this.isNestedInSource(root)) { + root = root.substring(0, separator); + separator = root.lastIndexOf('$'); } return root; diff --git a/enigma/src/main/java/org/quiltmc/enigma/impl/source/vineflower/VineflowerSource.java b/enigma/src/main/java/org/quiltmc/enigma/impl/source/vineflower/VineflowerSource.java index 6442d1ecb..e2f312459 100644 --- a/enigma/src/main/java/org/quiltmc/enigma/impl/source/vineflower/VineflowerSource.java +++ b/enigma/src/main/java/org/quiltmc/enigma/impl/source/vineflower/VineflowerSource.java @@ -94,14 +94,12 @@ private void checkDecompiled() { decompiler.decompileContext(); - EnigmaTextTokenCollector tokenCollector = null; + EnigmaTextTokenCollector tokenCollector; synchronized (tokenCollectors) { - for (EnigmaTextTokenCollector collector : tokenCollectors) { - if (collector.hasTokensFor(this.className)) { - tokenCollector = collector; - break; - } - } + tokenCollector = tokenCollectors.stream() + .filter(collector -> collector.hasTokensFor(this.className)) + .findFirst() + .orElse(null); } if (tokenCollector == null) { diff --git a/enigma/src/test/java/org/quiltmc/enigma/TestFlatInnerClasses.java b/enigma/src/test/java/org/quiltmc/enigma/TestFlatInnerClasses.java index dae49c729..cd761058a 100644 --- a/enigma/src/test/java/org/quiltmc/enigma/TestFlatInnerClasses.java +++ b/enigma/src/test/java/org/quiltmc/enigma/TestFlatInnerClasses.java @@ -1,10 +1,6 @@ package org.quiltmc.enigma; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import org.objectweb.asm.ClassWriter; -import org.objectweb.asm.MethodVisitor; -import org.objectweb.asm.Opcodes; import org.quiltmc.enigma.api.ProgressListener; import org.quiltmc.enigma.api.analysis.index.jar.InnerClassIndex; import org.quiltmc.enigma.api.analysis.index.jar.JarIndex; @@ -19,10 +15,7 @@ import org.quiltmc.enigma.api.translation.representation.entry.ClassEntry; import java.io.IOException; -import java.nio.file.Files; import java.nio.file.Path; -import java.util.jar.JarEntry; -import java.util.jar.JarOutputStream; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.is; @@ -30,109 +23,65 @@ import static org.hamcrest.Matchers.nullValue; public class TestFlatInnerClasses { - private static final ClassEntry OUTER = new ClassEntry("a/Outer"); - private static final ClassEntry INNER = new ClassEntry("a/Outer$Inner"); + private static final Path NESTED_JAR = TestUtil.obfJar("inner_classes"); + private static final Path FLAT_JAR = TestUtil.obfJar("flat_inner_classes"); - @TempDir - private Path tempDir; + private static final ClassEntry NESTED_OUTER = TestEntryFactory.newClass("d"); + private static final ClassEntry NESTED_INNER = TestEntryFactory.newClass("d$a"); + + private static final ClassEntry FLAT_OUTER = TestEntryFactory.newClass("org/quiltmc/enigma/input/flat_inner_classes/Outer"); + private static final ClassEntry FLAT_INNER = TestEntryFactory.newClass("org/quiltmc/enigma/input/flat_inner_classes/Outer$Inner"); @Test public void nestedInSourceWhenDeclared() throws IOException { - JarIndex index = this.index(this.writeJar("declared.jar", true)); - InnerClassIndex innerClasses = index.getIndex(InnerClassIndex.class); + InnerClassIndex innerClasses = index(NESTED_JAR).getIndex(InnerClassIndex.class); - assertThat(innerClasses.isNestedInSource(INNER), is(true)); - assertThat(innerClasses.getSourceRoot(INNER), is(OUTER)); - assertThat(innerClasses.getSourceRoot(OUTER), is(OUTER)); + assertThat(innerClasses.isNestedInSource(NESTED_INNER), is(true)); + assertThat(innerClasses.getSourceRoot(NESTED_INNER), is(NESTED_OUTER)); + assertThat(innerClasses.getSourceRoot(NESTED_OUTER), is(NESTED_OUTER)); } @Test public void notNestedInSourceWithoutTheAttribute() throws IOException { - JarIndex index = this.index(this.writeJar("stripped.jar", false)); - InnerClassIndex innerClasses = index.getIndex(InnerClassIndex.class); + InnerClassIndex innerClasses = index(FLAT_JAR).getIndex(InnerClassIndex.class); // the name still reads as nested, and the mappings still treat it as an inner class - assertThat(INNER.isInnerClass(), is(true)); + assertThat(FLAT_INNER.isInnerClass(), is(true)); - assertThat(innerClasses.isNestedInSource(INNER), is(false)); - assertThat(innerClasses.getSourceRoot(INNER), is(INNER)); + assertThat(innerClasses.isNestedInSource(FLAT_INNER), is(false)); + assertThat(innerClasses.getSourceRoot(FLAT_INNER), is(FLAT_INNER)); } @Test public void declaredInnerClassIsDecompiledIntoItsOuterClass() throws IOException { - SourceIndex source = this.decompile(this.writeJar("declared.jar", true), OUTER); + SourceIndex source = decompile(NESTED_JAR, NESTED_OUTER); - assertThat(source.getDeclarationToken(OUTER), is(notNullValue())); - assertThat(source.getDeclarationToken(INNER), is(notNullValue())); + assertThat(source.getDeclarationToken(NESTED_OUTER), is(notNullValue())); + assertThat(source.getDeclarationToken(NESTED_INNER), is(notNullValue())); } @Test public void flatInnerClassIsDecompiledOnItsOwn() throws IOException { - Path jar = this.writeJar("stripped.jar", false); - - SourceIndex innerSource = this.decompile(jar, INNER); - assertThat(innerSource.getDeclarationToken(INNER), is(notNullValue())); + SourceIndex innerSource = decompile(FLAT_JAR, FLAT_INNER); + assertThat(innerSource.getDeclarationToken(FLAT_INNER), is(notNullValue())); // its tokens can't leak into the class it is named after, whose file does not contain it - SourceIndex outerSource = this.decompile(jar, OUTER); - assertThat(outerSource.getDeclarationToken(OUTER), is(notNullValue())); - assertThat(outerSource.getDeclarationToken(INNER), is(nullValue())); + SourceIndex outerSource = decompile(FLAT_JAR, FLAT_OUTER); + assertThat(outerSource.getDeclarationToken(FLAT_OUTER), is(notNullValue())); + assertThat(outerSource.getDeclarationToken(FLAT_INNER), is(nullValue())); } - private SourceIndex decompile(Path jar, ClassEntry entry) throws IOException { + private static SourceIndex decompile(Path jar, ClassEntry entry) throws IOException { ClassProvider classProvider = new CachingClassProvider(new JarClassProvider(jar)); return Decompilers.VINEFLOWER.create(classProvider, new SourceSettings(false, false)) .getUndocumentedSource(entry.getFullName()) .index(); } - private JarIndex index(Path jar) throws IOException { + private static JarIndex index(Path jar) throws IOException { JarIndex index = MainJarIndex.empty(); ClassProvider classProvider = new CachingClassProvider(new JarClassProvider(jar)); index.indexJar(new ProjectClassProvider(classProvider, null), ProgressListener.createEmpty()); return index; } - - private Path writeJar(String name, boolean declareNesting) throws IOException { - Path jar = this.tempDir.resolve(name); - if (Files.exists(jar)) { - return jar; - } - - try (JarOutputStream out = new JarOutputStream(Files.newOutputStream(jar))) { - write(out, OUTER, declareNesting); - write(out, INNER, declareNesting); - } - - return jar; - } - - private static void write(JarOutputStream out, ClassEntry entry, boolean declareNesting) throws IOException { - ClassWriter writer = new ClassWriter(0); - writer.visit(Opcodes.V17, Opcodes.ACC_PUBLIC | Opcodes.ACC_SUPER, entry.getFullName(), null, "java/lang/Object", null); - - if (declareNesting) { - writer.visitInnerClass(INNER.getFullName(), OUTER.getFullName(), "Inner", Opcodes.ACC_STATIC); - } - - MethodVisitor init = writer.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null); - init.visitCode(); - init.visitVarInsn(Opcodes.ALOAD, 0); - init.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V", false); - init.visitInsn(Opcodes.RETURN); - init.visitMaxs(1, 1); - init.visitEnd(); - - MethodVisitor method = writer.visitMethod(Opcodes.ACC_PUBLIC, "run", "()V", null, null); - method.visitCode(); - method.visitInsn(Opcodes.RETURN); - method.visitMaxs(0, 1); - method.visitEnd(); - - writer.visitEnd(); - - out.putNextEntry(new JarEntry(entry.getFullName() + ".class")); - out.write(writer.toByteArray()); - out.closeEntry(); - } } diff --git a/enigma/src/test/java/org/quiltmc/enigma/input/flat_inner_classes/Outer.java b/enigma/src/test/java/org/quiltmc/enigma/input/flat_inner_classes/Outer.java new file mode 100644 index 000000000..0485aa6b4 --- /dev/null +++ b/enigma/src/test/java/org/quiltmc/enigma/input/flat_inner_classes/Outer.java @@ -0,0 +1,14 @@ +package org.quiltmc.enigma.input.flat_inner_classes; + +public class Outer { + private final Inner inner = new Inner(); + + public Inner getInner() { + return this.inner; + } + + public static class Inner { + public void run() { + } + } +} diff --git a/enigma/src/test/resources/proguard-flat_inner_classes-test.conf b/enigma/src/test/resources/proguard-flat_inner_classes-test.conf new file mode 100644 index 000000000..1734a8f80 --- /dev/null +++ b/enigma/src/test/resources/proguard-flat_inner_classes-test.conf @@ -0,0 +1,7 @@ +# this repro's an obfuscator that flattens inner classes in name only +-repackageclasses +-allowaccessmodification +-dontoptimize +-dontshrink +-keep class org.quiltmc.enigma.input.Keep +-keep class org.quiltmc.enigma.input.flat_inner_classes.**