fix: resolve class/package name conflicts on case-insensitive filesystems (PR #2828)

* Fix: resolve class/package name conflicts on case-insensitive filesystems

**RenameVisitor**
- Added package-level conflict detection for case-insensitive filesystems:
  when two packages differ only by case (e.g. com.Example vs com.example),
  rename the conflicting one and loop until the new name is also unique
- Added class-level conflict detection (same pattern): when two classes in
  the same package differ only by case (e.g. Sink vs sink), rename the
  conflicting one to prevent file overwrite on Windows export

**Tests**
- Added TestCaseSensitivePkgChecks: verifies package rename when packages
  differ only by case; fixed smali data (2.smali changed Bar→Foo to create
  a genuine path conflict under case-insensitive FS)
- Added TestCaseSensitiveClassInPkgChecks + smali fixtures: verifies class
  rename when two classes in a named package differ only by case
  (com.example.User vs com.example.user)

* Apply suggestions from code review

Co-authored-by: skylot <118523+skylot@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: skylot <118523+skylot@users.noreply.github.com>

---------

Co-authored-by: john <johnwsa@qq.com>
Co-authored-by: skylot <118523+skylot@users.noreply.github.com>
This commit is contained in:
John
2026-03-24 04:42:25 +08:00
committed by GitHub
parent 8b7d3f497e
commit b3d86ae908
7 changed files with 128 additions and 8 deletions
@@ -59,24 +59,43 @@ public class RenameVisitor extends AbstractVisitor {
checkFields(aliasProvider, cls, args);
checkMethods(aliasProvider, cls, args);
}
boolean pkgUpdated = false;
for (PackageNode pkg : root.getPackages()) {
pkgUpdated |= checkPackage(args, aliasProvider, pkg);
}
if (!args.isFsCaseSensitive() && args.isRenameCaseSensitive()) {
// check for package directory conflicts on case insensitive filesystems
Set<String> pkgPaths = new HashSet<>();
for (PackageNode pkg : root.getPackages()) {
String pkgPath = pkg.getAliasPkgInfo().getFullName().toLowerCase();
if (!pkgPaths.add(pkgPath)) {
pkg.setLeafAlias(aliasProvider.forPackage(pkg), false);
pkgUpdated = true;
// verify the new name also doesn't conflict
if (!pkgPaths.add(pkg.getAliasPkgInfo().getFullName().toLowerCase())) {
pkg.setLeafAlias(aliasProvider.forPackage(pkg), false);
}
}
}
}
if (pkgUpdated) {
root.runPackagesUpdate();
}
if (!args.isFsCaseSensitive() && args.isRenameCaseSensitive()) {
// check for class file conflicts on case insensitive filesystems (run after package rename)
Set<String> clsFullPaths = new HashSet<>(classes.size());
for (ClassNode cls : classes) {
ClassInfo clsInfo = cls.getClassInfo();
if (!clsFullPaths.add(clsInfo.getAliasFullPath().toLowerCase())) {
clsInfo.changeShortName(aliasProvider.forClass(cls));
cls.addAttr(new RenameReasonAttr(cls).append("case insensitive filesystem"));
clsFullPaths.add(clsInfo.getAliasFullPath().toLowerCase());
// verify the new name also doesn't conflict
if (!clsFullPaths.add(clsInfo.getAliasFullPath().toLowerCase())) {
clsInfo.changeShortName(aliasProvider.forClass(cls));
}
}
}
}
boolean pkgUpdated = false;
for (PackageNode pkg : root.getPackages()) {
pkgUpdated |= checkPackage(args, aliasProvider, pkg);
}
if (pkgUpdated) {
root.runPackagesUpdate();
}
processRootPackages(aliasProvider, root, classes);
}
@@ -0,0 +1,45 @@
package jadx.tests.integration.names;
import java.util.List;
import org.junit.jupiter.api.Test;
import jadx.core.dex.nodes.ClassNode;
import jadx.tests.api.SmaliTest;
import static jadx.tests.api.utils.assertj.JadxAssertions.assertThat;
public class TestCaseSensitiveClassInPkgChecks extends SmaliTest {
/*
* com.example.User and com.example.user - class names differ only by case in the same package.
* On case-insensitive FS both would map to the same file path, requiring class rename.
*/
@Test
public void testClassConflictOnCaseInsensitiveFS() {
args.setFsCaseSensitive(false);
List<ClassNode> classes = loadFromSmaliFiles();
assertThat(classes).hasSize(2);
long distinct = classes.stream()
.map(cls -> cls.getClassInfo().getAliasFullPath().toLowerCase())
.distinct()
.count();
assertThat(distinct).isEqualTo(2L);
}
@Test
public void testClassConflictOnCaseSensitiveFS() {
args.setFsCaseSensitive(true);
List<ClassNode> classes = loadFromSmaliFiles();
assertThat(classes).hasSize(2);
long distinct = classes.stream()
.map(cls -> cls.getClassInfo().getAliasFullPath())
.distinct()
.count();
assertThat(distinct).isEqualTo(2L);
}
}
@@ -0,0 +1,48 @@
package jadx.tests.integration.names;
import java.util.List;
import org.junit.jupiter.api.Test;
import jadx.core.dex.nodes.ClassNode;
import jadx.tests.api.SmaliTest;
import static jadx.tests.api.utils.assertj.JadxAssertions.assertThat;
public class TestCaseSensitivePkgChecks extends SmaliTest {
/*
* com.Example.Foo and com.example.Foo - same class name in packages that differ only by case.
* On case-insensitive FS both would map to the same path (com/example/foo), requiring package
* rename.
*/
@Test
public void testPkgConflictOnCaseInsensitiveFS() {
args.setFsCaseSensitive(false);
List<ClassNode> classes = loadFromSmaliFiles();
assertThat(classes).hasSize(2);
// all package paths must be distinct when lowercased (no two classes share same dir)
long distinctPkgPaths = classes.stream()
.map(cls -> cls.getClassInfo().getAliasFullPath().toLowerCase())
.distinct()
.count();
assertThat(distinctPkgPaths).isEqualTo(2L);
}
@Test
public void testPkgConflictOnCaseSensitiveFS() {
args.setFsCaseSensitive(true);
List<ClassNode> classes = loadFromSmaliFiles();
assertThat(classes).hasSize(2);
// on case-sensitive FS, original package names should be preserved
long distinctPkgPaths = classes.stream()
.map(cls -> cls.getClassInfo().getAliasFullPath())
.distinct()
.count();
assertThat(distinctPkgPaths).isEqualTo(2L);
}
}
@@ -0,0 +1,2 @@
.class public Lcom/example/User;
.super Ljava/lang/Object;
@@ -0,0 +1,2 @@
.class public Lcom/example/user;
.super Ljava/lang/Object;
@@ -0,0 +1,2 @@
.class public Lcom/Example/Foo;
.super Ljava/lang/Object;
@@ -0,0 +1,2 @@
.class public Lcom/example/Foo;
.super Ljava/lang/Object;