Skip to content

WW-5674 Cut the per-call allocations in SecurityMemberAccess package matching - #1830

Open
lukaszlenart wants to merge 10 commits into
mainfrom
WW-5674-isclassbelongstopackages-allocation
Open

WW-5674 Cut the per-call allocations in SecurityMemberAccess package matching#1830
lukaszlenart wants to merge 10 commits into
mainfrom
WW-5674-isclassbelongstopackages-allocation

Conversation

@lukaszlenart

@lukaszlenart lukaszlenart commented Aug 3, 2026

Copy link
Copy Markdown
Member

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:

  • WW-5674 (this PR) — the per-OGNL-access cost in isClassBelongsToPackages (sample 1).
  • WW-5675 — repeated config re-parsing caused by SecurityMemberAccess being a Scope.PROTOTYPE bean (sample 2). This is the dominant cost and is not addressed here.

Note that the fix proposed on WW-5667 (caching the parsed Set in a SecurityMemberAccess field) 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

isClassBelongsToPackages ran on the OGNL member-access path and, for an N-segment package, allocated a String[] plus its N substrings from split, a List.of wrapper, an IntStream pipeline, N subList views, and N StringJoiner-built strings. It is invoked up to four times per isAccessible() call.

  1. toPackageName now uses the cached Class.getPackageName() instead of Class.getPackage().getName(), which resolves through the defining classloader's package map on every call.
  2. The prefix construction is replaced by an in-place index walk, extracted into a package-private isPackageBelongsToPackages so the logic is testable against package-name shapes no real Class can produce.
  3. isClassAllowlisted walked 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 substring per 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:

  • Keeps a frozen, verbatim copy of the replaced implementation as a differential oracle, and asserts old and new agree across a matrix of 11 package-name shapes and 10 candidate sets, plus 11 class shapes for toPackageName.
  • Pins package-boundary safety explicitly: org.apache.struts2x must not match an org.apache.struts2 entry.
  • Pins the obscure default-package edge: 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 calls definePackage(). The only divergences are package names ending in ., which Class.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 resolve String[] to java.lang and java.io.File[] to java.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 explicit struts.allowlist.classes entry. As the allowlist is the primary OGNL defence in 7.x and is on by default, that question is deferred to WW-5676. The isArray() || 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 core module suite green: 3158 tests, 0 failures, 0 errors. SecurityMemberAccessTest passes unmodified — no existing assertion was changed.

lukaszlenart and others added 8 commits August 3, 2026 12:39
…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>
@lukaszlenart
lukaszlenart requested a review from Copilot August 3, 2026 12:13

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 toPackageName to use cached Class.getPackageName() with an isArray()/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.

@lukaszlenart lukaszlenart changed the title WW-5674 Make SecurityMemberAccess package matching allocation-free WW-5674 Cut the per-call allocations in SecurityMemberAccess package matching Aug 3, 2026
lukaszlenart and others added 2 commits August 3, 2026 18:01
…-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>
@lukaszlenart
lukaszlenart force-pushed the WW-5674-isclassbelongstopackages-allocation branch from 254dbfa to 33627a9 Compare August 3, 2026 16:02
@sonarqubecloud

sonarqubecloud Bot commented Aug 3, 2026

Copy link
Copy Markdown

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.

2 participants