Skip to content

Commit a9d4b98

Browse files
committed
Fix versioned bundles of equal contents being omitted from workspace tree
If you had 4 different versioned bundles with a basic module-info (same class bytes in each path) only one would show. This was due to the default behavior of PathNode#hasEqualOrChildValue which is now has an override in BundlePathNode.
1 parent 968c032 commit a9d4b98

5 files changed

Lines changed: 156 additions & 2 deletions

File tree

recaf-core/src/main/java/software/coley/recaf/path/BundlePathNode.java

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,19 +115,36 @@ public Set<String> directParentTypeIds() {
115115
return Set.of(ResourcePathNode.TYPE_ID);
116116
}
117117

118+
@Override
119+
public boolean hasEqualOrChildValue(@Nonnull PathNode<?> other) {
120+
// Bundle equality checks are abysmally slow, but also potentially problematic for path comparisons.
121+
// Two bundles may have the same contents, but they are not the same bundle.
122+
// We kill two birds with one stone by doing a reference check here.
123+
// If they are the same bundle, then they are the same path.
124+
if (other instanceof BundlePathNode otherBundlePath)
125+
return getValue() == otherBundlePath.getValue();
126+
return super.hasEqualOrChildValue(other);
127+
}
128+
118129
@Override
119130
public int localCompare(PathNode<?> o) {
120131
if (this == o)
121132
return 0;
122133

123134
if (o instanceof BundlePathNode bundlePathNode) {
135+
// Quick check for bundle reference equality. If they are the same bundle, then they are the same path.
136+
Bundle bundle = getValue();
137+
Bundle otherBundle = bundlePathNode.getValue();
138+
if (bundle == otherBundle)
139+
return 0;
140+
141+
// Order bundles by type.
142+
// This is a bitmask that encodes the bundle type which also doubles as order preference.
124143
int cmp = Integer.compare(bundleMask(), bundlePathNode.bundleMask());
125144
if (cmp != 0)
126145
return cmp;
127146

128147
// Order dex class bundles to be in alphabetical order.
129-
Bundle bundle = getValue();
130-
Object otherBundle = o.getValue();
131148
if (getParent() != null &&
132149
bundle instanceof AndroidClassBundle &&
133150
otherBundle instanceof AndroidClassBundle) {
@@ -145,6 +162,11 @@ public int localCompare(PathNode<?> o) {
145162
.orElse(null);
146163
return Named.STRING_PATH_COMPARATOR.compare(dexName, otherDexName);
147164
}
165+
// Order versioned JVM class bundles by version number.
166+
else if (bundle instanceof VersionedJvmClassBundle versionedBundle &&
167+
otherBundle instanceof VersionedJvmClassBundle otherVersionedBundle) {
168+
return Integer.compare(versionedBundle.version(), otherVersionedBundle.version());
169+
}
148170
}
149171
return 0;
150172
}

recaf-core/src/main/java/software/coley/recaf/workspace/model/bundle/BasicBundle.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,11 @@ private void resetHash() {
303303
hash = 0;
304304
}
305305

306+
@Override
307+
public String toString() {
308+
return getClass().getSimpleName() + "[" + backing.size() + " items]";
309+
}
310+
306311
@Override
307312
public boolean equals(Object o) {
308313
if (this == o)

recaf-core/src/main/java/software/coley/recaf/workspace/model/bundle/BasicVersionedJvmClassBundle.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,9 @@ public void onUpdateItem(@Nonnull String key, @Nonnull JvmClassInfo oldValue, @N
4242
public void onRemoveItem(@Nonnull String key, @Nonnull JvmClassInfo value) {
4343
// no-op
4444
}
45+
46+
@Override
47+
public String toString() {
48+
return super.toString() + " - version=" + version;
49+
}
4550
}

recaf-core/src/test/java/software/coley/recaf/path/PathNodeTest.java

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import software.coley.recaf.info.ClassInfo;
99
import software.coley.recaf.info.FileInfo;
1010
import software.coley.recaf.info.JvmClassInfo;
11+
import software.coley.recaf.info.StubClassInfo;
1112
import software.coley.recaf.info.annotation.Annotated;
1213
import software.coley.recaf.info.builder.AndroidClassInfoBuilder;
1314
import software.coley.recaf.info.builder.TextFileInfoBuilder;
@@ -18,6 +19,7 @@
1819
import software.coley.recaf.workspace.model.bundle.AndroidClassBundle;
1920
import software.coley.recaf.workspace.model.bundle.BasicAndroidClassBundle;
2021
import software.coley.recaf.workspace.model.bundle.BasicFileBundle;
22+
import software.coley.recaf.workspace.model.bundle.BasicVersionedJvmClassBundle;
2123
import software.coley.recaf.workspace.model.bundle.FileBundle;
2224
import software.coley.recaf.workspace.model.bundle.JvmClassBundle;
2325
import software.coley.recaf.workspace.model.resource.WorkspaceResource;
@@ -27,7 +29,9 @@
2729
import java.util.HashMap;
2830
import java.util.List;
2931
import java.util.Map;
32+
import java.util.NavigableMap;
3033
import java.util.Objects;
34+
import java.util.TreeMap;
3135

3236
import static org.assertj.core.api.Assertions.assertThat;
3337
import static org.assertj.core.api.Assertions.assertThatComparable;
@@ -317,6 +321,49 @@ void directoryNodesCanValidateParentChildRelationsFromPathValues() {
317321
assertThat(classB.isDescendantOf(classAAA)).isFalse();
318322
assertThat(classB.isDescendantOf(classB)).isTrue();
319323
}
324+
325+
/**
326+
* When classes are in different versioned bundles, they should not be considered descendants of each other,
327+
* even if they have the same package and class name. In this scenario all versioned bundles will also have
328+
* map equality which is another problem case that this test is designed to catch (with how paths handle
329+
* value equality checks by default, and how bundle paths override that behavior to address this problem).
330+
*/
331+
@Test
332+
void descendantOfDifferentVersionedClasses() {
333+
int min = 9;
334+
int max = 16;
335+
NavigableMap<Integer, ClassPathNode> versionedClasses = new TreeMap<>();
336+
NavigableMap<Integer, BundlePathNode> versionedBundles = new TreeMap<>();
337+
for (int i = min; i < max; i++) {
338+
BundlePathNode bundlePath = s4.child(new BasicVersionedJvmClassBundle(i));
339+
ClassPathNode classPath = bundlePath.child(null).child(new StubClassInfo("Foo"));
340+
versionedClasses.put(i, classPath);
341+
versionedBundles.put(i, bundlePath);
342+
}
343+
344+
// Validate that only classes from the same version are considered descendants of each other.
345+
Integer previousVersion = null;
346+
for (int a = min; a < max; a++) {
347+
for (int b = min; b < max; b++) {
348+
ClassPathNode classA = versionedClasses.get(a);
349+
ClassPathNode classB = versionedClasses.get(b);
350+
BundlePathNode bundleA = versionedBundles.get(a);
351+
BundlePathNode bundleB = versionedBundles.get(b);
352+
353+
if (a == b) {
354+
// Same version
355+
assertThat(classA.isDescendantOf(classB)).isTrue();
356+
assertThat(classA.isDescendantOf(bundleB)).isTrue();
357+
assertThat(bundleA.isDescendantOf(bundleB)).isTrue();
358+
} else {
359+
// Different version
360+
assertThat(classA.isDescendantOf(classB)).isFalse();
361+
assertThat(classA.isDescendantOf(bundleB)).isFalse();
362+
assertThat(bundleA.isDescendantOf(bundleB)).isFalse();
363+
}
364+
}
365+
}
366+
}
320367
}
321368

322369
@Nested

recaf-ui/src/test/java/software/coley/recaf/ui/control/tree/WorkspaceTreeNodeTest.java

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import software.coley.collections.Unchecked;
1010
import software.coley.recaf.info.BasicFileInfo;
1111
import software.coley.recaf.info.FileInfo;
12+
import software.coley.recaf.info.JarFileInfo;
1213
import software.coley.recaf.info.JvmClassInfo;
1314
import software.coley.recaf.info.Named;
1415
import software.coley.recaf.info.StubClassInfo;
@@ -22,15 +23,28 @@
2223
import software.coley.recaf.path.PathNodes;
2324
import software.coley.recaf.path.ResourcePathNode;
2425
import software.coley.recaf.path.WorkspacePathNode;
26+
import software.coley.recaf.services.text.TextFormatConfig;
27+
import software.coley.recaf.services.workspace.io.BasicClassPatcher;
28+
import software.coley.recaf.services.workspace.io.BasicInfoImporter;
29+
import software.coley.recaf.services.workspace.io.BasicResourceImporter;
30+
import software.coley.recaf.services.workspace.io.InfoImporterConfig;
31+
import software.coley.recaf.services.workspace.io.ResourceImporter;
32+
import software.coley.recaf.services.workspace.io.ResourceImporterConfig;
33+
import software.coley.recaf.test.TestClassUtils;
2534
import software.coley.recaf.test.dummy.AccessibleFields;
2635
import software.coley.recaf.test.dummy.HelloWorld;
2736
import software.coley.recaf.test.dummy.StringConsumer;
2837
import software.coley.recaf.test.dummy.VariedModifierFields;
38+
import software.coley.recaf.ui.config.WorkspaceExplorerConfig;
39+
import software.coley.recaf.util.ZipCreationUtils;
40+
import software.coley.recaf.util.io.ByteSource;
41+
import software.coley.recaf.util.io.ByteSources;
2942
import software.coley.recaf.workspace.model.BasicWorkspace;
3043
import software.coley.recaf.workspace.model.Workspace;
3144
import software.coley.recaf.workspace.model.bundle.BasicFileBundle;
3245
import software.coley.recaf.workspace.model.bundle.BasicJvmClassBundle;
3346
import software.coley.recaf.workspace.model.bundle.JvmClassBundle;
47+
import software.coley.recaf.workspace.model.bundle.VersionedJvmClassBundle;
3448
import software.coley.recaf.workspace.model.resource.WorkspaceFileResource;
3549
import software.coley.recaf.workspace.model.resource.WorkspaceFileResourceBuilder;
3650
import software.coley.recaf.workspace.model.resource.WorkspaceResource;
@@ -39,10 +53,13 @@
3953
import java.io.IOException;
4054
import java.util.ArrayList;
4155
import java.util.Collection;
56+
import java.util.Collections;
4257
import java.util.Comparator;
58+
import java.util.IdentityHashMap;
4359
import java.util.List;
4460
import java.util.Map;
4561
import java.util.Objects;
62+
import java.util.Set;
4663
import java.util.function.Function;
4764
import java.util.stream.Stream;
4865

@@ -54,6 +71,8 @@
5471
* Tests for {@link WorkspaceTreeNode}.
5572
*/
5673
class WorkspaceTreeNodeTest {
74+
static ResourceImporter importer;
75+
// Workspace model for testing - Used by multiple tests, but some will make their own.
5776
static Workspace workspace;
5877
static WorkspaceResource primaryResource;
5978
static WorkspaceResource resourceWithEmbedded;
@@ -84,6 +103,11 @@ class WorkspaceTreeNodeTest {
84103

85104
@BeforeAll
86105
static void setup() throws IOException {
106+
importer = new BasicResourceImporter(
107+
new BasicInfoImporter(new InfoImporterConfig(), new TextFormatConfig(), new BasicClassPatcher()),
108+
new ResourceImporterConfig()
109+
);
110+
87111
BasicFileBundle fileBundle = new BasicFileBundle();
88112

89113
primaryJvmBundle = fromClasses(
@@ -349,6 +373,57 @@ void nameOrdering() {
349373
});
350374
}
351375

376+
/**
377+
* While not an issue of name overloading in adjacent tree nodes, this test covers a case/regression where we
378+
* saw multiple classes with the same name in different versioned paths not being all shown.
379+
* This scenario has the effect of making all {@link VersionedJvmClassBundle} have map equality too which was
380+
* another issue with an older version of the path node path containment/equality logic.
381+
*/
382+
@Test
383+
void multipleVersionedPaths() throws Exception {
384+
String classPath = HelloWorld.class.getName().replace(".", "/");
385+
String classPackage = classPath.substring(0, classPath.lastIndexOf('/'));
386+
byte[] classBytes = TestClassUtils.fromRuntimeClass(HelloWorld.class).getBytecode();
387+
388+
// Create JAR with 'META-INF/versions/<dummyversion>/<dummypackage>/HelloWorld.class' for multiple versions.
389+
byte[] zipBytes = ZipCreationUtils.builder()
390+
.add(classPath + ".class", classBytes)
391+
.add(JarFileInfo.MULTI_RELEASE_PREFIX + "9/" + classPath + ".class", classBytes)
392+
.add(JarFileInfo.MULTI_RELEASE_PREFIX + "11/" + classPath + ".class", classBytes)
393+
.add(JarFileInfo.MULTI_RELEASE_PREFIX + "16/" + classPath + ".class", classBytes)
394+
.add(JarFileInfo.MULTI_RELEASE_PREFIX + "21/" + classPath + ".class", classBytes)
395+
.add(JarFileInfo.MULTI_RELEASE_PREFIX + "25/" + classPath + ".class", classBytes)
396+
.bytes();
397+
ByteSource zipSource = ByteSources.wrap(zipBytes);
398+
399+
// Build the workspace and validate the versioned bundles exist.
400+
WorkspaceResource resource = importer.importResource(zipSource);
401+
BasicWorkspace workspace = new BasicWorkspace(resource);
402+
assertEquals(5, resource.getVersionedJvmClassBundles().size(), "Expected 5 versioned bundles: 9, 11, 16, 21, 25");
403+
404+
// Build the tree model.
405+
WorkspacePathNode rootPath = PathNodes.workspacePath(workspace);
406+
WorkspaceRootTreeNode root = new WorkspaceRootTreeNode(new WorkspaceExplorerConfig(), rootPath);
407+
root.build();
408+
409+
// Iterate over all versions, validate the path to the versioned class exists and that no duplicate tree paths exist.
410+
final int baseline = -1;
411+
int[] versions = new int[]{baseline, 9, 11, 16, 21, 25};
412+
Set<BundlePathNode> visitedNodes = Collections.newSetFromMap(new IdentityHashMap<>());
413+
for (int version : versions) {
414+
String versionName = version == baseline ? "baseline" : String.valueOf(version);
415+
ClassPathNode path = version == baseline ?
416+
workspace.findJvmClass(classPath) :
417+
workspace.findVersionedJvmClass(classPath, version);
418+
assertNotNull(path, "Could not find class path for version: " + versionName);
419+
WorkspaceTreeNode classNode = root.getNodeByPath(path);
420+
assertNotNull(classNode, "Could not find tree node for class path for version: " + versionName);
421+
BundlePathNode bundleNode = path.getParent().getParent();
422+
assertTrue(visitedNodes.add(bundleNode), "Duplicate bundle node found for version: " + versionName);
423+
}
424+
assertEquals(6, visitedNodes.size(), "Expected 6 unique bundle nodes for versions: baseline, 9, 11, 16, 21, 25");
425+
}
426+
352427
/**
353428
* The named path sorter was getting confused when checking "does 'a' have 'b' as a parent-directory" if either
354429
* String was empty, which is the case for classes in the default package. This led to inconsistent return value

0 commit comments

Comments
 (0)