proguard icon indicating copy to clipboard operation
proguard copied to clipboard

Using a switch on a class field which is an interface implemented by an Enum without explicitly casting to an enum will use the default branch every time after obfuscating

Open charon25 opened this issue 6 months ago • 4 comments

Replicated in Proguard 7.7.0 and JDK Temurin-21.0.7+6.

Simple reproduction case :

package org.example;

public class TestClass {
  public interface TestInterface {
    int getValue();
  }

  public enum TestEnum implements TestInterface {
    A, B, C, D;

    @Override
    public int getValue() {
      return ordinal();
    }
  }

  private TestInterface m_field;

  public void setField(final TestInterface field) {
    m_field = field;
  }

  public int method1() {
    switch (m_field) {
      case TestEnum.A:
        return 1;
      case TestEnum.B:
        return 2;
      case TestEnum.C:
        return 3;
      case TestEnum.D:
        return 4;
      default:
        return 100;
    }
  }

  public int method2() {
    switch ((TestEnum) m_field) {
      case TestEnum.A:
        return 1;
      case TestEnum.B:
        return 2;
      case TestEnum.C:
        return 3;
      case TestEnum.D:
        return 4;
      default:
        return 100;
    }
  }

  public static void main(String[] args) {
    final TestClass test = new TestClass();
    test.setField(TestEnum.B);
    System.out.println("test.method1()=" + test.method1());
    System.out.println("test.method2()=" + test.method2());
  }
}

The complete project with the Maven's pom.xml is available in the given zip archive ProguardSwitchBug.zip. When running the command to obfuscate and run :

mvn clean package && java -cp target/ProguardSwitchBug-1.0-SNAPSHOT-shaded-obfuscated.jar org.example.TestClass

The output is

test.method1()=100
test.method2()=2

When the expected result is the same for both methods.

When looking at the obfuscated code with the IntelliJ IDEA decompiler, the difference is obvious :

method1 :

b var10000 = this.a;
Objects.requireNonNull(var10000);
b var1 = var10000;
byte var2 = 0;
switch (var1.typeSwitch<invokedynamic>(var1, var2)) {
  ...
}

method2 :

switch (((a)this.a).ordinal()) {
  ...
}

charon25 avatar Jun 24 '25 07:06 charon25

I think I know what the issue is. The broken version gets compiled to this invokedynamic call:

$ javap -v org.example.TestClass
public int method1();
    descriptor: ()I
    flags: (0x0001) ACC_PUBLIC
    Code:
    ...
        12: aload_1
        13: iload_2
        14: invokedynamic #19,  0             // InvokeDynamic #0:typeSwitch:(Ljava/lang/Object;I)I
        19: tableswitch   { // 0 to 3
                       0: 48
                       1: 52
                       2: 56
                       3: 60
                 default: 64
            }
  
 #19 = InvokeDynamic      #0:#20        // #0:typeSwitch:(Ljava/lang/Object;I)I
   ...
   
   BootstrapMethods:
  0: #115 REF_invokeStatic java/lang/runtime/SwitchBootstraps.typeSwitch:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;
    Method arguments:
      #79 #3:invoke:Ljava/lang/Enum$EnumDesc;
      #83 #4:invoke:Ljava/lang/Enum$EnumDesc;
      #84 #5:invoke:Ljava/lang/Enum$EnumDesc;
      #85 #6:invoke:Ljava/lang/Enum$EnumDesc;

The four method arguments refer 4 dynamic constants, and if you check the referenced bootstrap method index, you will see that all of them pass another dynamic constant with bootstrap method index 7. If we look at that boostrap method index, we see that the second argument is a string constant "org.example.TestClass$TestEnum", which is not updated to the new name after obfuscation. Since the call is matching a non existant value, it collapses to the default branch:

  3: #127 REF_invokeStatic java/lang/invoke/ConstantBootstraps.invoke:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/Class;Ljava/lang/invoke/MethodHandle;[Ljava/lang/Object;)Ljava/lang/Object;
    Method arguments:
      #90 REF_invokeStatic java/lang/Enum$EnumDesc.of:(Ljava/lang/constant/ClassDesc;Ljava/lang/String;)Ljava/lang/Enum$EnumDesc;
      #97 #7:invoke:Ljava/lang/constant/ClassDesc;
      #100 A
  4: #127 REF_invokeStatic java/lang/invoke/ConstantBootstraps.invoke:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/Class;Ljava/lang/invoke/MethodHandle;[Ljava/lang/Object;)Ljava/lang/Object;
    Method arguments:
      #90 REF_invokeStatic java/lang/Enum$EnumDesc.of:(Ljava/lang/constant/ClassDesc;Ljava/lang/String;)Ljava/lang/Enum$EnumDesc;
      #97 #7:invoke:Ljava/lang/constant/ClassDesc;
      #102 B
  5: #127 REF_invokeStatic java/lang/invoke/ConstantBootstraps.invoke:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/Class;Ljava/lang/invoke/MethodHandle;[Ljava/lang/Object;)Ljava/lang/Object;
    Method arguments:
      #90 REF_invokeStatic java/lang/Enum$EnumDesc.of:(Ljava/lang/constant/ClassDesc;Ljava/lang/String;)Ljava/lang/Enum$EnumDesc;
      #97 #7:invoke:Ljava/lang/constant/ClassDesc;
      #103 C
  6: #127 REF_invokeStatic java/lang/invoke/ConstantBootstraps.invoke:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/Class;Ljava/lang/invoke/MethodHandle;[Ljava/lang/Object;)Ljava/lang/Object;
    Method arguments:
      #90 REF_invokeStatic java/lang/Enum$EnumDesc.of:(Ljava/lang/constant/ClassDesc;Ljava/lang/String;)Ljava/lang/Enum$EnumDesc;
      #97 #7
7: #127 REF_invokeStatic java/lang/invoke/ConstantBootstraps.invoke:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/Class;Ljava/lang/invoke/MethodHandle;[Ljava/lang/Object;)Ljava/lang/Object;
    Method arguments:
      #107 REF_invokeStatic java/lang/constant/ClassDesc.of:(Ljava/lang/String;)Ljava/lang/constant/ClassDesc;
      #113 org.example.TestClass$TestEnum

I think we're missing a class reference initialization in the ClassReferenceInitializer (source here), you can workaround this with these two options in the maven config:

<option> -adaptclassstrings org.example.TestClass$TestEnum </option>
<option> 
-keepclassmembers enum * { 
   *** values();
   *** valueOf();
}
</option>

or add them to a text configuration like this:

-adaptclassstrings org.example.TestClass
-keepclassmembers enum * { 
   *** values();
   *** valueOf();
}

First option makes sure that the string is adapted, the second one is necessary when processing enum classes, You will see that it's also present in the default android configuration.

Depending on where the enum is used in a pattern matching switch, you will need to add an adaptclassstrings rule for each class doing pattern matching. You can also just keep the enum class name:

-keep class org.example.TestClass$TestEnum

-keepclassmembers enum * { 
   *** values();
   *** valueOf();
}

P.S: Many thanks for the reproducing sample, was very useful while debugging.

piazzesiNiccolo-GS avatar Jun 30 '25 06:06 piazzesiNiccolo-GS

Hi, thanks a lot for the investigation!

I tested with the following configuration and it doesn't work

<option> -adaptclassstrings org.example.TestClass$TestEnum </option>
<option> 
-keepclassmembers enum * { 
   *** values();
   *** valueOf();
}
</option>

But it works with -adaptclassstring org.example.TestClass instead of following instruction -adaptclassstrings org.example.TestClass$TestEnum (Probably because the switch is in TestClass and not in TestClass$TestEnum)

For the enum processing we've been using the following configuration for many years and it seems sufficient (up to proguard-base version 7.6.1 and since them with the adaptclassstring directive specified above for version 7.7.0)

<option>
-keepclassmembers,allowshrinking,allowoptimization class * extends java.lang.Enum {
   public static **[] values();
   public static ** valueOf(java.lang.String);
}
</option>

We've removed it from this example at first to keep this reproduction sample as simple as possible :x (I don't know if this has any impact, so I'd prefer to specify it here)

SineAnkama avatar Jun 30 '25 07:06 SineAnkama

Yes that's indeed the correct adaptclassstrings rule, apologies for the confusion. The rule must target classes where the enum is used, not the enum per se.

piazzesiNiccolo-GS avatar Jun 30 '25 11:06 piazzesiNiccolo-GS

We've removed it from this example at first to keep this reproduction sample as simple as possible :x (I don't know if this has any impact, so I'd prefer to specify it here)

That's also sufficient yes, the two rules are equivalent. It was necessary in my local testing to keep it working, but that's just because (I assume) the invokedynamic uses the values result under the hood.

piazzesiNiccolo-GS avatar Jun 30 '25 11:06 piazzesiNiccolo-GS