From 1b56f58999046051d76a653922c3ab72b4db9cf7 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:10:15 +0200 Subject: [PATCH] Use depth-aware bracket parsing in PropertyAccessorUtils Previously, canonicalPropertyName() located the end of a [key] expression via a naive indexOf("]") search, while AbstractNestablePropertyAccessor.getPropertyNameKeyEnd() -- used during actual property resolution -- tracked bracket nesting depth. This meant the two methods could disagree on the canonical form of a property path whose map key itself contains bracket characters (e.g., map['key[0]']). Similarly, getNestedPropertySeparatorIndex() tracked whether a dot separator occurs inside a [key] expression using a simple boolean toggle that flips on both '[' and ']', which produces an incorrect result when a key contains an odd net count of inner bracket characters. To address those inconsistencies, this commit extracts the private getPropertyNameKeyEnd() method from AbstractNestablePropertyAccessor to a package-private static utility method in PropertyAccessorUtils, so that canonicalPropertyName() can reuse the same depth-aware bracket matching, and getNestedPropertySeparatorIndex() has been reworked to track bracket nesting depth instead of toggling a boolean flag. Closes gh-36999 --- .../AbstractNestablePropertyAccessor.java | 28 +--------- .../beans/PropertyAccessorUtils.java | 54 ++++++++++++++++--- .../beans/PropertyAccessorUtilsTests.java | 19 +++++++ 3 files changed, 67 insertions(+), 34 deletions(-) diff --git a/spring-beans/src/main/java/org/springframework/beans/AbstractNestablePropertyAccessor.java b/spring-beans/src/main/java/org/springframework/beans/AbstractNestablePropertyAccessor.java index 4c763b480f5..4f5cce3407c 100644 --- a/spring-beans/src/main/java/org/springframework/beans/AbstractNestablePropertyAccessor.java +++ b/spring-beans/src/main/java/org/springframework/beans/AbstractNestablePropertyAccessor.java @@ -938,7 +938,7 @@ public abstract class AbstractNestablePropertyAccessor extends AbstractPropertyA int keyStart = propertyName.indexOf(PROPERTY_KEY_PREFIX, searchIndex); searchIndex = -1; if (keyStart != -1) { - int keyEnd = getPropertyNameKeyEnd(propertyName, keyStart + PROPERTY_KEY_PREFIX.length()); + int keyEnd = PropertyAccessorUtils.getPropertyNameKeyEnd(propertyName, keyStart + PROPERTY_KEY_PREFIX.length()); if (keyEnd != -1) { if (actualName == null) { actualName = propertyName.substring(0, keyStart); @@ -963,32 +963,6 @@ public abstract class AbstractNestablePropertyAccessor extends AbstractPropertyA return tokens; } - private int getPropertyNameKeyEnd(String propertyName, int startIndex) { - int unclosedPrefixes = 0; - int length = propertyName.length(); - for (int i = startIndex; i < length; i++) { - switch (propertyName.charAt(i)) { - case PropertyAccessor.PROPERTY_KEY_PREFIX_CHAR -> { - // The property name contains opening prefix(es)... - unclosedPrefixes++; - } - case PropertyAccessor.PROPERTY_KEY_SUFFIX_CHAR -> { - if (unclosedPrefixes == 0) { - // No unclosed prefix(es) in the property name (left) -> - // this is the suffix we are looking for. - return i; - } - else { - // This suffix does not close the initial prefix but rather - // just one that occurred within the property name. - unclosedPrefixes--; - } - } - } - } - return -1; - } - @Override public String toString() { String className = getClass().getName(); diff --git a/spring-beans/src/main/java/org/springframework/beans/PropertyAccessorUtils.java b/spring-beans/src/main/java/org/springframework/beans/PropertyAccessorUtils.java index f527f83bd93..d305ba6bfcf 100644 --- a/spring-beans/src/main/java/org/springframework/beans/PropertyAccessorUtils.java +++ b/spring-beans/src/main/java/org/springframework/beans/PropertyAccessorUtils.java @@ -23,6 +23,7 @@ import org.jspecify.annotations.Nullable; * according to the {@link PropertyAccessor} interface. * * @author Juergen Hoeller + * @author Sam Brannen * @since 1.2.6 */ public abstract class PropertyAccessorUtils { @@ -81,21 +82,23 @@ public abstract class PropertyAccessorUtils { /** * Determine the first (or last) nested property separator in the * given property path, ignoring dots in keys (like "map[my.key]"). + *

Also tracks bracket nesting depth so that keys containing an unbalanced + * number of {@code [} or {@code ]} characters do not interfere with the + * detection of separators that precede or follow the key. * @param propertyPath the property path to check * @param last whether to return the last separator rather than the first * @return the index of the nested property separator, or -1 if none */ private static int getNestedPropertySeparatorIndex(String propertyPath, boolean last) { - boolean inKey = false; + int depth = 0; int length = propertyPath.length(); int i = (last ? length - 1 : 0); while (last ? i >= 0 : i < length) { switch (propertyPath.charAt(i)) { - case PropertyAccessor.PROPERTY_KEY_PREFIX_CHAR, PropertyAccessor.PROPERTY_KEY_SUFFIX_CHAR -> { - inKey = !inKey; - } + case PropertyAccessor.PROPERTY_KEY_PREFIX_CHAR -> depth += (last ? -1 : 1); + case PropertyAccessor.PROPERTY_KEY_SUFFIX_CHAR -> depth += (last ? 1 : -1); case PropertyAccessor.NESTED_PROPERTY_SEPARATOR_CHAR -> { - if (!inKey) { + if (depth <= 0) { return i; } } @@ -150,8 +153,7 @@ public abstract class PropertyAccessorUtils { int keyStart = sb.indexOf(PropertyAccessor.PROPERTY_KEY_PREFIX, searchIndex); searchIndex = -1; if (keyStart != -1) { - int keyEnd = sb.indexOf( - PropertyAccessor.PROPERTY_KEY_SUFFIX, keyStart + PropertyAccessor.PROPERTY_KEY_PREFIX.length()); + int keyEnd = getPropertyNameKeyEnd(sb, keyStart + PropertyAccessor.PROPERTY_KEY_PREFIX.length()); if (keyEnd != -1) { String key = sb.substring(keyStart + PropertyAccessor.PROPERTY_KEY_PREFIX.length(), keyEnd); if (key.length() > 1 && ((key.startsWith("'") && key.endsWith("'")) || @@ -185,4 +187,42 @@ public abstract class PropertyAccessorUtils { return result; } + /** + * Determine the end of the {@code [key]} expression that starts at the + * given {@code startIndex}, tracking bracket nesting depth so that any + * {@code [} or {@code ]} characters contained within the key itself (for + * example, in a quoted key such as {@code "['key[0]']"}) do not interfere + * with the detection of the actual closing bracket. + * @param propertyName the property name (or path) to search + * @param startIndex the index to start searching from (immediately after + * the opening {@code [}) + * @return the index of the matching closing {@code ]}, or -1 if none + * @since 7.1 + */ + static int getPropertyNameKeyEnd(CharSequence propertyName, int startIndex) { + int unclosedPrefixes = 0; + int length = propertyName.length(); + for (int i = startIndex; i < length; i++) { + switch (propertyName.charAt(i)) { + case PropertyAccessor.PROPERTY_KEY_PREFIX_CHAR -> { + // The property name contains opening prefix(es)... + unclosedPrefixes++; + } + case PropertyAccessor.PROPERTY_KEY_SUFFIX_CHAR -> { + if (unclosedPrefixes == 0) { + // No unclosed prefix(es) in the property name (left) -> + // this is the suffix we are looking for. + return i; + } + else { + // This suffix does not close the initial prefix but rather + // just one that occurred within the property name. + unclosedPrefixes--; + } + } + } + } + return -1; + } + } diff --git a/spring-beans/src/test/java/org/springframework/beans/PropertyAccessorUtilsTests.java b/spring-beans/src/test/java/org/springframework/beans/PropertyAccessorUtilsTests.java index 0af975de003..2602e95f1e2 100644 --- a/spring-beans/src/test/java/org/springframework/beans/PropertyAccessorUtilsTests.java +++ b/spring-beans/src/test/java/org/springframework/beans/PropertyAccessorUtilsTests.java @@ -49,12 +49,24 @@ class PropertyAccessorUtilsTests { void getFirstNestedPropertySeparatorIndex() { assertThat(PropertyAccessorUtils.getFirstNestedPropertySeparatorIndex("[user]")).isEqualTo(-1); assertThat(PropertyAccessorUtils.getFirstNestedPropertySeparatorIndex("user.name")).isEqualTo(4); + assertThat(PropertyAccessorUtils.getFirstNestedPropertySeparatorIndex("map[key[0]].name")).isEqualTo(11); + + // A dot nested two (unclosed) bracket levels deep must not be mistaken + // for a top-level separator, even though the net number of brackets + // seen so far is even. + assertThat(PropertyAccessorUtils.getFirstNestedPropertySeparatorIndex("a[b[c.d]]")).isEqualTo(-1); } @Test void getLastNestedPropertySeparatorIndex() { assertThat(PropertyAccessorUtils.getLastNestedPropertySeparatorIndex("[user]")).isEqualTo(-1); assertThat(PropertyAccessorUtils.getLastNestedPropertySeparatorIndex("user.address.street")).isEqualTo(12); + assertThat(PropertyAccessorUtils.getLastNestedPropertySeparatorIndex("map[key[0]].name")).isEqualTo(11); + + // Symmetric case to the one above, but scanning from the end: a dot + // preceded (from the right) by two unmatched closing brackets must + // not be mistaken for a top-level separator. + assertThat(PropertyAccessorUtils.getLastNestedPropertySeparatorIndex("d.c]b]a")).isEqualTo(-1); } @Test @@ -83,6 +95,13 @@ class PropertyAccessorUtilsTests { assertThat(PropertyAccessorUtils.canonicalPropertyName("map[\"key1]")).isEqualTo("map[\"key1]"); assertThat(PropertyAccessorUtils.canonicalPropertyName("map[']")).isEqualTo("map[']"); assertThat(PropertyAccessorUtils.canonicalPropertyName("map[\"]")).isEqualTo("map[\"]"); + + // Keys that themselves contain bracket characters must be resolved + // using depth-aware parsing, consistent with how the property + // accessor resolves the same paths during actual data binding. + assertThat(PropertyAccessorUtils.canonicalPropertyName("map[\"key[0]\"]")).isEqualTo("map[key[0]]"); + assertThat(PropertyAccessorUtils.canonicalPropertyName("map['key[0]'].name")).isEqualTo("map[key[0]].name"); + assertThat(PropertyAccessorUtils.canonicalPropertyName("users['admin[0]']")).isEqualTo("users[admin[0]]"); } @Test