Skip to content

Commit ccbfe7c

Browse files
committed
Prevent unbounded indexed binding when fallback value is non-null
When a @ConfigurationProperties List element's property holds a non-null default value (such as Optional.empty()), IndexedElementsBinder would loop indefinitely because JavaBeanBinder treated the fallback value as a successfully bound element. This regression was introduced in 4.1.0 where fallback-to-default behavior changed, turning a previously bounded BindException into an OutOfMemoryError. The fix introduces PropertyBinding, a record that captures both the bound value and whether it originated from a configuration source. JavaBeanBinder and ValueObjectBinder now use fromSource() to determine if binding actually occurred, rather than relying on null checks that fail for non-null default values like Optional.empty(). fromSource is determined by checking both direct property match (findProperty) and descendant presence (containsDescendantOf=PRESENT), ensuring indexed and nested properties are correctly recognized as source-bound while non-iterable sources that return UNKNOWN are correctly treated as non-source. See gh-50831 Signed-off-by: yangzhice <a13644015167@qq.com>
1 parent c731cfd commit ccbfe7c

5 files changed

Lines changed: 112 additions & 10 deletions

File tree

core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/Binder.java

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@
3131
import java.util.function.Supplier;
3232

3333
import org.jspecify.annotations.Nullable;
34-
3534
import org.springframework.beans.PropertyEditorRegistry;
3635
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
3736
import org.springframework.boot.context.properties.bind.Bindable.BindRestriction;
@@ -495,6 +494,15 @@ public <T> T bindOrCreate(ConfigurationPropertyName name, Bindable<T> target, @N
495494
return result;
496495
}
497496

497+
private boolean hasDescendantInAnySource(Context context, ConfigurationPropertyName name) {
498+
for (ConfigurationPropertySource source : context.getSources()) {
499+
if (source.containsDescendantOf(name) == ConfigurationPropertyState.PRESENT) {
500+
return true;
501+
}
502+
}
503+
return false;
504+
}
505+
498506
private @Nullable Object bindDataObject(ConfigurationPropertyName name, Bindable<?> target, BindHandler handler,
499507
Context context, boolean allowRecursiveBinding, boolean fallbackToDefaultValue) {
500508
if (isUnbindableBean(name, target, context)) {
@@ -505,8 +513,13 @@ public <T> T bindOrCreate(ConfigurationPropertyName name, Bindable<T> target, @N
505513
if (!allowRecursiveBinding && context.isBindingDataObject(type)) {
506514
return null;
507515
}
508-
DataObjectPropertyBinder propertyBinder = (propertyName, propertyTarget) -> bind(name.append(propertyName),
509-
propertyTarget, handler, context, false, false);
516+
DataObjectPropertyBinder propertyBinder = (propertyName, propertyTarget) -> {
517+
ConfigurationPropertyName fullName = name.append(propertyName);
518+
ConfigurationProperty property = findProperty(fullName, propertyTarget, context);
519+
boolean fromSource = (property != null) || hasDescendantInAnySource(context, fullName);
520+
Object value = bind(fullName, propertyTarget, handler, context, false, false);
521+
return new DataObjectPropertyBinder.PropertyBinding(value, fromSource);
522+
};
510523
Supplier<@Nullable Object> supplier = () -> fromDataObjectBinders(bindMethod,
511524
(dataObjectBinder) -> dataObjectBinder.bind(name, target, context, propertyBinder,
512525
fallbackToDefaultValue));

core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/DataObjectPropertyBinder.java

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,22 @@
2727
*/
2828
interface DataObjectPropertyBinder {
2929

30+
/**
31+
* The result of binding a single property.
32+
*
33+
* @param value the bound value or {@code null}
34+
* @param fromSource whether the value was bound from a configuration source
35+
*/
36+
record PropertyBinding(@Nullable Object value, boolean fromSource) {
37+
}
38+
3039
/**
3140
* Bind the given property.
3241
* @param propertyName the property name (in lowercase dashed form, e.g.
3342
* {@code first-name})
3443
* @param target the target bindable
35-
* @return the bound value or {@code null}
44+
* @return the binding result (never {@code null})
3645
*/
37-
@Nullable Object bindProperty(String propertyName, Bindable<?> target);
46+
PropertyBinding bindProperty(String propertyName, Bindable<?> target);
3847

3948
}

core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/JavaBeanBinder.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,12 @@ private <T> boolean bind(BeanSupplier<T> beanSupplier, DataObjectPropertyBinder
124124
ResolvableType type = property.getType();
125125
Supplier<Object> value = property.getValue(beanSupplier);
126126
Annotation[] annotations = property.getAnnotations();
127-
Object bound = propertyBinder.bindProperty(propertyName,
127+
DataObjectPropertyBinder.PropertyBinding binding = propertyBinder.bindProperty(propertyName,
128128
Bindable.of(type).withSuppliedValue(value).withAnnotations(annotations));
129+
if (!binding.fromSource()) {
130+
return false;
131+
}
132+
Object bound = binding.value();
129133
if (bound == null) {
130134
return false;
131135
}

core/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/ValueObjectBinder.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,9 @@ class ValueObjectBinder implements DataObjectBinder {
8585
List<@Nullable Object> args = new ArrayList<>(parameters.size());
8686
boolean bound = false;
8787
for (ConstructorParameter parameter : parameters) {
88-
Object arg = parameter.bind(propertyBinder);
89-
bound = bound || arg != null;
88+
DataObjectPropertyBinder.PropertyBinding binding = parameter.bind(propertyBinder);
89+
bound = bound || binding.fromSource();
90+
Object arg = binding.value();
9091
arg = (arg != null) ? arg : getDefaultValue(context, parameter);
9192
args.add(arg);
9293
}
@@ -384,7 +385,7 @@ private static class ConstructorParameter {
384385
this.annotations = annotations;
385386
}
386387

387-
@Nullable Object bind(DataObjectPropertyBinder propertyBinder) {
388+
DataObjectPropertyBinder.PropertyBinding bind(DataObjectPropertyBinder propertyBinder) {
388389
return propertyBinder.bindProperty(this.name, Bindable.of(this.type).withAnnotations(this.annotations));
389390
}
390391

core/spring-boot/src/test/java/org/springframework/boot/context/properties/bind/JavaBeanBinderTests.java

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import java.util.LinkedHashSet;
2626
import java.util.List;
2727
import java.util.Map;
28+
import java.util.Optional;
2829
import java.util.Set;
2930
import java.util.concurrent.atomic.AtomicInteger;
3031

@@ -34,6 +35,7 @@
3435
import org.springframework.boot.context.properties.bind.JavaBeanBinder.Bean;
3536
import org.springframework.boot.context.properties.bind.JavaBeanBinder.BeanProperty;
3637
import org.springframework.boot.context.properties.bind.handler.IgnoreErrorsBindHandler;
38+
import org.springframework.boot.context.properties.source.ConfigurationProperty;
3739
import org.springframework.boot.context.properties.source.ConfigurationPropertyName;
3840
import org.springframework.boot.context.properties.source.ConfigurationPropertySource;
3941
import org.springframework.boot.context.properties.source.MockConfigurationPropertySource;
@@ -44,6 +46,7 @@
4446
import org.springframework.format.annotation.DateTimeFormat;
4547

4648
import static org.assertj.core.api.Assertions.assertThat;
49+
import static org.assertj.core.api.Assertions.assertThatCode;
4750
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
4851
import static org.assertj.core.api.Assertions.entry;
4952

@@ -1275,4 +1278,76 @@ public String toString() {
12751278

12761279
}
12771280

1278-
}
1281+
@Test
1282+
void bindToListWithOptionalFieldAndNonIterableSourceShouldNotLoopInfinitely() {
1283+
ConfigurationPropertySource source = new ConfigurationPropertySource() {
1284+
@Override
1285+
public @Nullable ConfigurationProperty getConfigurationProperty(ConfigurationPropertyName name) {
1286+
return null;
1287+
}
1288+
};
1289+
this.sources.add(source);
1290+
assertThatCode(() -> this.binder.bind("foo", Bindable.of(ListOfOptionalExample.class)))
1291+
.doesNotThrowAnyException();
1292+
}
1293+
1294+
@Test
1295+
void bindToBeanWithOptionalFieldAndActualConfigurationShouldBindValue() {
1296+
MockConfigurationPropertySource source = new MockConfigurationPropertySource();
1297+
source.put("foo.optional-value", "hello");
1298+
this.sources.add(source);
1299+
ExampleWithOptional bound = this.binder.bind("foo", Bindable.of(ExampleWithOptional.class)).get();
1300+
assertThat(bound.getOptionalValue()).contains("hello");
1301+
}
1302+
1303+
@Test
1304+
void bindToBeanWithOptionalFieldAndNoConfigurationShouldNotBind() {
1305+
MockConfigurationPropertySource source = new MockConfigurationPropertySource();
1306+
this.sources.add(source);
1307+
ExampleWithOptional bound = this.binder.bind("foo", Bindable.of(ExampleWithOptional.class)).orElse(null);
1308+
assertThat(bound).isNull();
1309+
}
1310+
1311+
static class ListOfOptionalExample {
1312+
1313+
private List<OptionalItem> items = new ArrayList<>();
1314+
1315+
List<OptionalItem> getItems() {
1316+
return this.items;
1317+
}
1318+
1319+
void setItems(List<OptionalItem> items) {
1320+
this.items = items;
1321+
}
1322+
1323+
}
1324+
1325+
static class OptionalItem {
1326+
1327+
private Optional<String> regex = Optional.empty();
1328+
1329+
Optional<String> getRegex() {
1330+
return this.regex;
1331+
}
1332+
1333+
void setRegex(Optional<String> regex) {
1334+
this.regex = regex;
1335+
}
1336+
1337+
}
1338+
1339+
static class ExampleWithOptional {
1340+
1341+
private Optional<String> optionalValue = Optional.empty();
1342+
1343+
Optional<String> getOptionalValue() {
1344+
return this.optionalValue;
1345+
}
1346+
1347+
void setOptionalValue(Optional<String> optionalValue) {
1348+
this.optionalValue = optionalValue;
1349+
}
1350+
1351+
}
1352+
1353+
}

0 commit comments

Comments
 (0)