Skip to content

Fix MethodUtils.invokeMethod on instances of non-public classes - #1783

Merged
garydgregory merged 3 commits into
apache:masterfrom
lenamonj:reflect-accessible-fast-path
Sep 9, 2026
Merged

Fix MethodUtils.invokeMethod on instances of non-public classes#1783
garydgregory merged 3 commits into
apache:masterfrom
lenamonj:reflect-accessible-fast-path

Conversation

@lenamonj

@lenamonj lenamonj commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

getMatchingAccessibleMethod has an exact-signature fast path, added on master in ee09f69, that returns the Method declared on the receiver's class without the accessible-declaration lookup the search path does. When that class is not public, as with every JDK collection factory result, invokeMethod throws IllegalAccessException although a public declaration exists on the interface.

The fast path now re-resolves an instance hit through getAccessibleMethod and keeps the original when no public declaration exists. A static hit is kept as is, since a static method hides rather than overrides; the interface walk skips static interface methods, which are not inherited.

Tests: the first four are red on master, the two static cases were red on the first commit. Default Maven goal green.

  • Read the contribution guidelines for this project.
  • Read the ASF Generative Tooling Guidance if you use Artificial Intelligence (AI).
  • I used AI to create any part of, or all of, this pull request. Which AI tool was used to create this pull request, and to what extent did it contribute? Claude (Anthropic) found the defect and drafted the fix and tests; I reviewed them, reproduced the failure on a fresh clone of master, and ran the default Maven goal.
  • Run a successful build using the default Maven goal with mvn; that's mvn on the command line by itself.
  • Write unit tests that match behavioral changes, where the tests fail if the changes to the runtime are not applied. This may not always be possible, but it is a best practice.
  • Write a pull request description that is detailed enough to understand what the pull request does, how, and why.
  • Each commit in the pull request should have a meaningful subject line and body. Note that a maintainer may squash commits during the merge process.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new JDK List.size() assertions expect List.class as the declaring type, but the public declaration typically resolves to java.util.Collection, making the test incorrect/brittle.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR fixes MethodUtils.getMatchingAccessibleMethod’s exact-signature fast path so that when the receiver is a non-public class (common for JDK factory collections), the returned Method is re-resolved to a publicly accessible declaration (e.g., via an interface) to avoid IllegalAccessException during invokeMethod.

Changes:

  • Update getMatchingAccessibleMethod fast path to prefer getAccessibleMethod(cls, candidate) when available.
  • Add new unit tests covering non-public receiver classes (JDK collections and package-private test classes) and the invokeMethod regression scenario.
File summaries
File Description
src/main/java/org/apache/commons/lang3/reflect/MethodUtils.java Re-resolves exact-match hits through getAccessibleMethod to avoid returning inaccessible declarations from non-public receiver classes.
src/test/java/org/apache/commons/lang3/reflect/MethodUtilsTest.java Adds tests that exercise accessible-method resolution and invocation on non-public receiver classes (including JDK collection factories).
Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +620 to +623
assertSame(List.class,
MethodUtils.getMatchingAccessibleMethod(Collections.emptyList().getClass(), "size").getDeclaringClass());
assertSame(List.class,
MethodUtils.getMatchingAccessibleMethod(Arrays.asList(1, 2).getClass(), "size").getDeclaringClass());

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

java.util.List declares size() itself, so the interface walk in getAccessibleMethodFromInterfaceNest stops at List before it reaches Collection, and Map declares its own size() the same way. The assertions hold on every JDK in the matrix, 8 through 27.

@garydgregory

Copy link
Copy Markdown
Member

I think this PR creates 2 bugs so it looks like we are missing some tests since the build was green, so please add these missing tests and update the main code:

  • At the new getAccessibleMethod(cls, candidate) call, a package-private subclass’s public static method can be replaced by a same-signature method on its public superclass. Static methods do not dispatch to subclass implementations.
    With Child.who() returning "child" and Parent.who() returning "parent":
    MethodUtils.invokeStaticMethod(Child.class, "who")
    Before PR: "child"
    After PR: "parent"
    So you should preserve the existing exact-match behavior for static candidates.

  • Reject static interface methods as replacements for instance methods.
    The interface lookup uses getDeclaredMethod() without excluding static methods. A public interface can declare static String label(), while its package-private implementing class independently declares an instance String label().
    MethodUtils.invokeMethod(instance, "label")
    Before PR: "instance"
    After PR: "interface-static"
    Reflection ignores the receiver when invoking that static method. Skipping resolution for static candidates alone does not fix this case: replacement declarations must also be checked.

Note: Your AI is imagining things when it talks about PR #1427 because that PR was closed without being merged.

@garydgregory
garydgregory marked this pull request as draft September 8, 2026 23:19
@lenamonj
lenamonj marked this pull request as ready for review September 9, 2026 00:21
@lenamonj

lenamonj commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Both cases reproduced on the first commit and are fixed in efc8e2e: a static candidate is kept as is, since a static method hides rather than overrides, and the interface walk skips static interface methods, which are not inherited. Tests for both use your examples (child, instance), red on the first commit and green now; the original four still pass. On #1427: the fast path came in with ee09f69 on master, whose subject cites #1427, and #1427 itself was closed unmerged; the body is corrected. Default goal green with -Ddoclint=all; marked ready for review.

@garydgregory

Copy link
Copy Markdown
Member

Here's another wrinkle: On Java 9 and up, interfaces can contain private instance methods, which are not inherited and cannot serve as accessible replacements. So you need to exclude private interface methods as well as static methods.

Since our code is Java 8-based, the only way to test this is to add a precompiled Java code to the test resource folder.

For example:

public interface Labels {
    private String label() { return "helper"; }
}

static class Bean implements Labels {
    public String label() { return "bean"; }
}

For MethodUtils.invokeMethod(new Bean(), "label"):

  • The original code: returns "bean".
  • The PE selects the Labels private method and throws IllegalAccessException.

@garydgregory
garydgregory marked this pull request as draft September 9, 2026 01:21
A private interface method is not inherited, so it cannot stand in for the implementing class's own method.
@lenamonj
lenamonj marked this pull request as ready for review September 9, 2026 02:02
@lenamonj

lenamonj commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Pushed the private interface method case. getAccessibleMethodFromInterfaceNest now returns a declared interface method only when it is public and not static, so a private one falls through to the class's own public method. Without that change the new test errors with IllegalAccessException on your example, and it passes with it.

The fixture pair is compiled with javac release 9 and checked in under src/test/resources next to its sources, since test sources build at 1.8. The test assumes Java 9 or later, so the Java 8 jobs skip it. Default goal green locally with -Ddoclint=all. The workflow runs on this head are action_required.

@garydgregory
garydgregory merged commit d8f4116 into apache:master Sep 9, 2026
23 of 24 checks passed
@garydgregory

Copy link
Copy Markdown
Member

Thank you @lenamonj , merged 🚀

@lenamonj
lenamonj deleted the reflect-accessible-fast-path branch September 9, 2026 06:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants