WW-5674 Cut the per-call allocations in SecurityMemberAccess package matching - #1830
WW-5674 Cut the per-call allocations in SecurityMemberAccess package matching#1830lukaszlenart wants to merge 10 commits into
Conversation
…ckages Covers sample 1 of WW-5667: the per-OGNL-access split/stream/join cost in SecurityMemberAccess.isClassBelongsToPackages. Sample 2 (config re-parsing caused by the PROTOTYPE bean scope) is tracked separately as WW-5675. Records the current prefix-matching semantics verified against JDK 17, including the default-package contains("") edge reachable via struts.excludedPackageNames="." and the unreachable trailing-dot divergence. Keeps array and primitive package semantics unchanged, since adopting getPackageName() there tightens the exclusion list but loosens the allowlist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ge matching Four TDD tasks: characterise current behaviour, swap toPackageName to the cached getPackageName() behind an array/primitive guard, replace the split/stream prefix construction with an index walk, then collapse the allowlist path's two walks into one. Task 1 is a characterisation suite that must pass against unmodified code; a failure there means the spec's semantic claims are wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pins the current behaviour of isClassBelongsToPackages and toPackageName before the WW-5674 rewrite, including the default-package empty-string edge reachable via struts.excludedPackageNames="." and the package-boundary case where org.apache.struts2x must not match org.apache.struts2.
…Name getPackage() performs a classloader package-map lookup on every call; the name returned by getPackageName() is computed once and cached on the Class. The isArray()/isPrimitive() guard covers exactly the inputs for which getPackage() returns null, so results are unchanged for every class shape.
…refixes Replaces the split/IntStream/String.join prefix construction with an index walk, extracted into a pure package-private helper so it can be tested against package name shapes no real Class can produce. Per call this drops a String[], a list wrapper, a stream pipeline, N sublist views and N joined strings, leaving one substring per package level. Equivalence with the replaced implementation is asserted over a matrix of package name shapes and candidate sets.
isClassAllowlisted walked the class's package name twice, once for ALLOWLIST_REQUIRED_PACKAGES and once for the configured allowlist. A two-set overload probes both sets at each prefix, halving the work on a path that runs for every OGNL member access. Asserted equivalent to OR-ing the two single-set calls across a matrix of class shapes and candidate sets. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sibling-package test asserted only against the test-local copy of the replaced implementation, so it would have stayed green even if the production walk were gutted. It now asserts on both the live helper and the frozen oracle. Also narrows the three-argument isClassBelongsToPackages overload to package-private: it has a single caller and its test is in the same package, and public static on a public class is frozen API until the next major release. Adds a candidate set that makes the consecutive-dot prefix the deciding probe, and corrects two inaccuracies in the design document.
…rload The three-argument isClassBelongsToPackages was narrowed to package-private during the final review, but section 3 still showed it as public static and still carried the superseded justification for publishing it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Note
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Optimizes Struts OGNL security package matching (WW-5674) to reduce CPU/allocations in SecurityMemberAccess by replacing split/streams prefix building with an index-walk and using Class.getPackageName() (guarded to preserve array/primitive semantics).
Changes:
- Reworked package-prefix matching to a single-pass dot-index walk and added package-private helpers for testability.
- Switched
toPackageNameto use cachedClass.getPackageName()with anisArray()/isPrimitive()guard to preserve legacy behavior. - Added focused JUnit 4 tests proving behavioral equivalence vs the legacy implementation (including edge cases like default package and prefix boundaries).
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| docs/superpowers/specs/2026-08-03-WW-5674-isclassbelongstopackages-allocation-design.md | New design/spec documenting the optimization approach and equivalence rationale. |
| docs/superpowers/plans/2026-08-03-WW-5674-isclassbelongstopackages-allocation.md | New implementation plan detailing step-by-step tasks and test strategy. |
| core/src/main/java/org/apache/struts2/ognl/SecurityMemberAccess.java | Implements the new index-walk matching, two-set overload, and getPackageName() usage. |
| core/src/test/java/org/apache/struts2/ognl/SecurityMemberAccessPackageMatchingTest.java | New characterization/differential tests locking in behavior and guarding regressions. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…-dot direction Copilot's review is right that "allocation-free" overclaims: the walk still creates one substring per package level. What it removes is everything around that. Retitles the spec and plan accordingly and softens the goal statement. Documents on isPackageBelongsToPackages that its one divergence from the replaced implementation is directional. A package name ending in '.' probes one prefix more, which tightens exclusion but loosens the allowlist. No caller can produce one today, but the helper is a package-private pure String function, so a future caller routing some other string through it would inherit the problem. Also aligns the plan with the package-private overload it now ships, and lists WW-5676 and WW-5677 as filed rather than pending, including checkDefaultPackageAccess which the spec previously omitted from its out-of-scope list. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Records why the three-argument overload is package-private while sharing a name with a public method, and that renaming it — plus narrowing the two public statics with no external callers — is tracked against 8.0.0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
254dbfa to
33627a9
Compare
|



Fixes WW-5674, a sub-task of WW-5667.
Background
WW-5667 reports OGNL security checks consuming 9% of RUNNABLE CPU samples in a 2-minute JFR profile under load. The report contains two stack samples pointing at two independent problems, so it was split:
isClassBelongsToPackages(sample 1).SecurityMemberAccessbeing aScope.PROTOTYPEbean (sample 2). This is the dominant cost and is not addressed here.Note that the fix proposed on WW-5667 (caching the parsed
Setin aSecurityMemberAccessfield) addresses neither problem — it does not touch this hot path, and it cannot help sample 2 because the instance holding the field is discarded and rebuilt on each container lookup.What changed
isClassBelongsToPackagesran on the OGNL member-access path and, for an N-segment package, allocated aString[]plus its N substrings fromsplit, aList.ofwrapper, anIntStreampipeline, NsubListviews, and NStringJoiner-built strings. It is invoked up to four times perisAccessible()call.toPackageNamenow uses the cachedClass.getPackageName()instead ofClass.getPackage().getName(), which resolves through the defining classloader's package map on every call.isPackageBelongsToPackagesso the logic is testable against package-name shapes no realClasscan produce.isClassAllowlistedwalked the same package name twice, once per allowlist set. A package-private two-set overload now probes both sets at each prefix, halving that work.This is allocation-reduced, not allocation-free: the walk still creates one
substringper package level. What it removes is everything around that.Behaviour is unchanged
This is the OGNL security gate, so the change is a pure optimisation with zero semantic change, proven rather than asserted. The committed suite:
toPackageName.org.apache.struts2xmust not match anorg.apache.struts2entry.struts.excludedPackageNames="."strips to"", which still excludes default-package classes.Beyond the committed tests, equivalence was also checked with throwaway harnesses during development and independently during review — exhaustively over every string on
{a, b, .}up to length 8, and over a wider set of class shapes including hidden classes, JDK proxies and classes defined by a classloader that never callsdefinePackage(). The only divergences are package names ending in., whichClass.getPackageName()cannot produce. That divergence is directional and is now documented on the helper: a trailing dot probes one prefix more, tightening exclusion but loosening the allowlist.Array and primitive package semantics are deliberately unchanged.
getPackageName()would resolveString[]tojava.langandjava.io.File[]tojava.io, where the current code yields"". That change is bidirectional — it tightens the exclusion list but loosens the allowlist, since arrays of allowlisted-package types would become reachable without an explicitstruts.allowlist.classesentry. As the allowlist is the primary OGNL defence in 7.x and is on by default, that question is deferred to WW-5676. TheisArray() || isPrimitive()guard preserves current behaviour exactly.Two remaining
getPackage()call sites in the same file (checkDefaultPackageAccess,isExcludedPackageNamePatterns) are left alone here to keep this reviewable as one concern, and are tracked as WW-5677.Testing
Full
coremodule suite green: 3158 tests, 0 failures, 0 errors.SecurityMemberAccessTestpasses unmodified — no existing assertion was changed.