From e1ab306506d4e350b119331387f504b726fbc776 Mon Sep 17 00:00:00 2001 From: Sam Brannen <104798+sbrannen@users.noreply.github.com> Date: Tue, 6 Aug 2024 11:33:03 +0300 Subject: [PATCH] Enforce read-only semantics in SpEL's SimpleEvaluationContext SimpleEvaluationContext.forReadOnlyDataBinding() documents that it creates a SimpleEvaluationContext for read-only access to public properties; however, prior to this commit write access was not disabled for indexed structures when using the assignment operator, the increment operator, or the decrement operator. In order to better align with the documented contract for forReadOnlyDataBinding(), this commit makes it possible to disable assignment in general in order to enforce read-only semantics for SpEL's SimpleEvaluationContext when created via the forReadOnlyDataBinding() factory method. Specifically: - This commit introduces a new isAssignmentEnabled() "default" method in the EvaluationContext API, which returns true by default. - SimpleEvaluationContext overrides isAssignmentEnabled(), returning false if the context was created via the forReadOnlyDataBinding() factory method. - The Assign, OpDec, and OpInc AST nodes -- representing the assignment (=), increment (++), and decrement (--) operators, respectively -- now throw a SpelEvaluationException if assignment is disabled for the current EvaluationContext. See gh-33319 Closes gh-33321 (cherry picked from commit 0127de5a7a80319a9a7973ad125002309a591a77) --- .../expression/EvaluationContext.java | 16 +- .../expression/spel/ast/Assign.java | 7 +- .../expression/spel/ast/OpDec.java | 6 +- .../expression/spel/ast/OpInc.java | 8 +- .../spel/support/SimpleEvaluationContext.java | 77 ++- .../spel/CompilableMapAccessor.java | 117 +++++ .../expression/spel/PropertyAccessTests.java | 8 +- .../spel/SpelCompilationCoverageTests.java | 78 --- .../support/SimpleEvaluationContextTests.java | 477 ++++++++++++++++++ 9 files changed, 685 insertions(+), 109 deletions(-) create mode 100644 spring-expression/src/test/java/org/springframework/expression/spel/CompilableMapAccessor.java create mode 100644 spring-expression/src/test/java/org/springframework/expression/spel/support/SimpleEvaluationContextTests.java diff --git a/spring-expression/src/main/java/org/springframework/expression/EvaluationContext.java b/spring-expression/src/main/java/org/springframework/expression/EvaluationContext.java index 0c393a86dbe..5bb6c46e0d6 100644 --- a/spring-expression/src/main/java/org/springframework/expression/EvaluationContext.java +++ b/spring-expression/src/main/java/org/springframework/expression/EvaluationContext.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -132,4 +132,18 @@ public interface EvaluationContext { @Nullable Object lookupVariable(String name); + /** + * Determine if assignment is enabled within expressions evaluated by this evaluation + * context. + *

If this method returns {@code false}, the assignment ({@code =}), increment + * ({@code ++}), and decrement ({@code --}) operators are disabled. + *

By default, this method returns {@code true}. Concrete implementations may override + * this default method to disable assignment. + * @return {@code true} if assignment is enabled; {@code false} otherwise + * @since 5.3.38 + */ + default boolean isAssignmentEnabled() { + return true; + } + } diff --git a/spring-expression/src/main/java/org/springframework/expression/spel/ast/Assign.java b/spring-expression/src/main/java/org/springframework/expression/spel/ast/Assign.java index 55e5d2e4ff0..1b47ead1607 100644 --- a/spring-expression/src/main/java/org/springframework/expression/spel/ast/Assign.java +++ b/spring-expression/src/main/java/org/springframework/expression/spel/ast/Assign.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,6 +19,8 @@ package org.springframework.expression.spel.ast; import org.springframework.expression.EvaluationException; import org.springframework.expression.TypedValue; import org.springframework.expression.spel.ExpressionState; +import org.springframework.expression.spel.SpelEvaluationException; +import org.springframework.expression.spel.SpelMessage; /** * Represents assignment. An alternative to calling {@code setValue} @@ -39,6 +41,9 @@ public class Assign extends SpelNodeImpl { @Override public TypedValue getValueInternal(ExpressionState state) throws EvaluationException { + if (!state.getEvaluationContext().isAssignmentEnabled()) { + throw new SpelEvaluationException(getStartPosition(), SpelMessage.NOT_ASSIGNABLE, toStringAST()); + } return this.children[0].setValueInternal(state, () -> this.children[1].getValueInternal(state)); } diff --git a/spring-expression/src/main/java/org/springframework/expression/spel/ast/OpDec.java b/spring-expression/src/main/java/org/springframework/expression/spel/ast/OpDec.java index d61e8d64106..ce15fdc6072 100644 --- a/spring-expression/src/main/java/org/springframework/expression/spel/ast/OpDec.java +++ b/spring-expression/src/main/java/org/springframework/expression/spel/ast/OpDec.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -51,6 +51,10 @@ public class OpDec extends Operator { @Override public TypedValue getValueInternal(ExpressionState state) throws EvaluationException { + if (!state.getEvaluationContext().isAssignmentEnabled()) { + throw new SpelEvaluationException(getStartPosition(), SpelMessage.OPERAND_NOT_DECREMENTABLE, toStringAST()); + } + SpelNodeImpl operand = getLeftOperand(); // The operand is going to be read and then assigned to, we don't want to evaluate it twice. diff --git a/spring-expression/src/main/java/org/springframework/expression/spel/ast/OpInc.java b/spring-expression/src/main/java/org/springframework/expression/spel/ast/OpInc.java index f6dc184c0f5..59640a1858a 100644 --- a/spring-expression/src/main/java/org/springframework/expression/spel/ast/OpInc.java +++ b/spring-expression/src/main/java/org/springframework/expression/spel/ast/OpInc.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2021 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -51,6 +51,10 @@ public class OpInc extends Operator { @Override public TypedValue getValueInternal(ExpressionState state) throws EvaluationException { + if (!state.getEvaluationContext().isAssignmentEnabled()) { + throw new SpelEvaluationException(getStartPosition(), SpelMessage.OPERAND_NOT_INCREMENTABLE, toStringAST()); + } + SpelNodeImpl operand = getLeftOperand(); ValueRef valueRef = operand.getValueRef(state); @@ -104,7 +108,7 @@ public class OpInc extends Operator { } } - // set the name value + // set the new value try { valueRef.setValue(newValue.getValue()); } diff --git a/spring-expression/src/main/java/org/springframework/expression/spel/support/SimpleEvaluationContext.java b/spring-expression/src/main/java/org/springframework/expression/spel/support/SimpleEvaluationContext.java index 1168c9c91a2..8e0f11be50f 100644 --- a/spring-expression/src/main/java/org/springframework/expression/spel/support/SimpleEvaluationContext.java +++ b/spring-expression/src/main/java/org/springframework/expression/spel/support/SimpleEvaluationContext.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2023 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -51,25 +51,25 @@ import org.springframework.lang.Nullable; * SpEL language syntax, e.g. excluding references to Java types, constructors, * and bean references. * - *

When creating a {@code SimpleEvaluationContext} you need to choose the - * level of support that you need for property access in SpEL expressions: + *

When creating a {@code SimpleEvaluationContext} you need to choose the level of + * support that you need for data binding in SpEL expressions: *

* - *

Conveniently, {@link SimpleEvaluationContext#forReadOnlyDataBinding()} - * enables read access to properties via {@link DataBindingPropertyAccessor}; - * same for {@link SimpleEvaluationContext#forReadWriteDataBinding()} when - * write access is needed as well. Alternatively, configure custom accessors - * via {@link SimpleEvaluationContext#forPropertyAccessors}, and potentially - * activate method resolution and/or a type converter through the builder. + *

Conveniently, {@link SimpleEvaluationContext#forReadOnlyDataBinding()} enables + * read-only access to properties via {@link DataBindingPropertyAccessor}. Similarly, + * {@link SimpleEvaluationContext#forReadWriteDataBinding()} enables read and write access + * to properties. Alternatively, configure custom accessors via + * {@link SimpleEvaluationContext#forPropertyAccessors} and potentially activate method + * resolution and/or a type converter through the builder. * *

Note that {@code SimpleEvaluationContext} is typically not configured * with a default root object. Instead it is meant to be created once and - * used repeatedly through {@code getValue} calls on a pre-compiled + * used repeatedly through {@code getValue} calls on a predefined * {@link org.springframework.expression.Expression} with both an * {@code EvaluationContext} and a root object as arguments: * {@link org.springframework.expression.Expression#getValue(EvaluationContext, Object)}. @@ -81,9 +81,9 @@ import org.springframework.lang.Nullable; * @author Juergen Hoeller * @author Sam Brannen * @since 4.3.15 - * @see #forPropertyAccessors * @see #forReadOnlyDataBinding() * @see #forReadWriteDataBinding() + * @see #forPropertyAccessors * @see StandardEvaluationContext * @see StandardTypeConverter * @see DataBindingPropertyAccessor @@ -109,14 +109,17 @@ public final class SimpleEvaluationContext implements EvaluationContext { private final Map variables = new HashMap<>(); + private final boolean assignmentEnabled; + private SimpleEvaluationContext(List accessors, List resolvers, - @Nullable TypeConverter converter, @Nullable TypedValue rootObject) { + @Nullable TypeConverter converter, @Nullable TypedValue rootObject, boolean assignmentEnabled) { this.propertyAccessors = accessors; this.methodResolvers = resolvers; this.typeConverter = (converter != null ? converter : new StandardTypeConverter()); this.rootObject = (rootObject != null ? rootObject : TypedValue.NULL); + this.assignmentEnabled = assignmentEnabled; } @@ -224,15 +227,33 @@ public final class SimpleEvaluationContext implements EvaluationContext { return this.variables.get(name); } + /** + * Determine if assignment is enabled within expressions evaluated by this evaluation + * context. + *

If this method returns {@code false}, the assignment ({@code =}), increment + * ({@code ++}), and decrement ({@code --}) operators are disabled. + * @return {@code true} if assignment is enabled; {@code false} otherwise + * @since 5.3.38 + * @see #forPropertyAccessors(PropertyAccessor...) + * @see #forReadOnlyDataBinding() + * @see #forReadWriteDataBinding() + */ + @Override + public boolean isAssignmentEnabled() { + return this.assignmentEnabled; + } /** * Create a {@code SimpleEvaluationContext} for the specified {@link PropertyAccessor} * delegates: typically a custom {@code PropertyAccessor} specific to a use case * (e.g. attribute resolution in a custom data structure), potentially combined with * a {@link DataBindingPropertyAccessor} if property dereferences are needed as well. + *

Assignment is enabled within expressions evaluated by the context created via + * this factory method. * @param accessors the accessor delegates to use * @see DataBindingPropertyAccessor#forReadOnlyAccess() * @see DataBindingPropertyAccessor#forReadWriteAccess() + * @see #isAssignmentEnabled() */ public static Builder forPropertyAccessors(PropertyAccessor... accessors) { for (PropertyAccessor accessor : accessors) { @@ -241,34 +262,40 @@ public final class SimpleEvaluationContext implements EvaluationContext { "ReflectivePropertyAccessor. Consider using DataBindingPropertyAccessor or a custom subclass."); } } - return new Builder(accessors); + return new Builder(true, accessors); } /** * Create a {@code SimpleEvaluationContext} for read-only access to * public properties via {@link DataBindingPropertyAccessor}. + *

Assignment is disabled within expressions evaluated by the context created via + * this factory method. * @see DataBindingPropertyAccessor#forReadOnlyAccess() * @see #forPropertyAccessors + * @see #isAssignmentEnabled() */ public static Builder forReadOnlyDataBinding() { - return new Builder(DataBindingPropertyAccessor.forReadOnlyAccess()); + return new Builder(false, DataBindingPropertyAccessor.forReadOnlyAccess()); } /** * Create a {@code SimpleEvaluationContext} for read-write access to * public properties via {@link DataBindingPropertyAccessor}. + *

Assignment is enabled within expressions evaluated by the context created via + * this factory method. * @see DataBindingPropertyAccessor#forReadWriteAccess() * @see #forPropertyAccessors + * @see #isAssignmentEnabled() */ public static Builder forReadWriteDataBinding() { - return new Builder(DataBindingPropertyAccessor.forReadWriteAccess()); + return new Builder(true, DataBindingPropertyAccessor.forReadWriteAccess()); } /** * Builder for {@code SimpleEvaluationContext}. */ - public static class Builder { + public static final class Builder { private final List accessors; @@ -280,10 +307,15 @@ public final class SimpleEvaluationContext implements EvaluationContext { @Nullable private TypedValue rootObject; - public Builder(PropertyAccessor... accessors) { + private final boolean assignmentEnabled; + + + private Builder(boolean assignmentEnabled, PropertyAccessor... accessors) { + this.assignmentEnabled = assignmentEnabled; this.accessors = Arrays.asList(accessors); } + /** * Register the specified {@link MethodResolver} delegates for * a combination of property access and method resolution. @@ -362,7 +394,8 @@ public final class SimpleEvaluationContext implements EvaluationContext { } public SimpleEvaluationContext build() { - return new SimpleEvaluationContext(this.accessors, this.resolvers, this.typeConverter, this.rootObject); + return new SimpleEvaluationContext(this.accessors, this.resolvers, this.typeConverter, this.rootObject, + this.assignmentEnabled); } } diff --git a/spring-expression/src/test/java/org/springframework/expression/spel/CompilableMapAccessor.java b/spring-expression/src/test/java/org/springframework/expression/spel/CompilableMapAccessor.java new file mode 100644 index 00000000000..0d065f5bb29 --- /dev/null +++ b/spring-expression/src/test/java/org/springframework/expression/spel/CompilableMapAccessor.java @@ -0,0 +1,117 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.expression.spel; + +import java.util.Map; + +import org.springframework.asm.MethodVisitor; +import org.springframework.expression.AccessException; +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.TypedValue; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * This is a local COPY of {@link org.springframework.context.expression.MapAccessor}. + * + * @author Juergen Hoeller + * @author Andy Clement + * @since 4.1 + */ +public class CompilableMapAccessor implements CompilablePropertyAccessor { + + @Override + public Class[] getSpecificTargetClasses() { + return new Class[] {Map.class}; + } + + @Override + public boolean canRead(EvaluationContext context, @Nullable Object target, String name) throws AccessException { + return (target instanceof Map map && map.containsKey(name)); + } + + @Override + public TypedValue read(EvaluationContext context, @Nullable Object target, String name) throws AccessException { + Assert.state(target instanceof Map, "Target must be of type Map"); + Map map = (Map) target; + Object value = map.get(name); + if (value == null && !map.containsKey(name)) { + throw new MapAccessException(name); + } + return new TypedValue(value); + } + + @Override + public boolean canWrite(EvaluationContext context, @Nullable Object target, String name) throws AccessException { + return true; + } + + @Override + @SuppressWarnings("unchecked") + public void write(EvaluationContext context, @Nullable Object target, String name, @Nullable Object newValue) + throws AccessException { + + Assert.state(target instanceof Map, "Target must be a Map"); + Map map = (Map) target; + map.put(name, newValue); + } + + @Override + public boolean isCompilable() { + return true; + } + + @Override + public Class getPropertyType() { + return Object.class; + } + + @Override + public void generateCode(String propertyName, MethodVisitor mv, CodeFlow cf) { + String descriptor = cf.lastDescriptor(); + if (descriptor == null || !descriptor.equals("Ljava/util/Map")) { + if (descriptor == null) { + cf.loadTarget(mv); + } + CodeFlow.insertCheckCast(mv, "Ljava/util/Map"); + } + mv.visitLdcInsn(propertyName); + mv.visitMethodInsn(INVOKEINTERFACE, "java/util/Map", "get","(Ljava/lang/Object;)Ljava/lang/Object;",true); + } + + + /** + * Exception thrown from {@code read} in order to reset a cached + * PropertyAccessor, allowing other accessors to have a try. + */ + @SuppressWarnings("serial") + private static class MapAccessException extends AccessException { + + private final String key; + + public MapAccessException(String key) { + super(""); + this.key = key; + } + + @Override + public String getMessage() { + return "Map does not contain a value for key '" + this.key + "'"; + } + } + +} diff --git a/spring-expression/src/test/java/org/springframework/expression/spel/PropertyAccessTests.java b/spring-expression/src/test/java/org/springframework/expression/spel/PropertyAccessTests.java index 69f67c32528..680c0dd63c8 100644 --- a/spring-expression/src/test/java/org/springframework/expression/spel/PropertyAccessTests.java +++ b/spring-expression/src/test/java/org/springframework/expression/spel/PropertyAccessTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2002-2022 the original author or authors. + * Copyright 2002-2024 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -188,11 +188,11 @@ public class PropertyAccessTests extends AbstractExpressionTests { assertThatSpelEvaluationException() .isThrownBy(() -> parser.parseExpression("name='p3'").getValue(context, target)) - .extracting(SpelEvaluationException::getMessageCode).isEqualTo(SpelMessage.PROPERTY_OR_FIELD_NOT_WRITABLE); + .extracting(SpelEvaluationException::getMessageCode).isEqualTo(SpelMessage.NOT_ASSIGNABLE); assertThatSpelEvaluationException() .isThrownBy(() -> parser.parseExpression("['name']='p4'").getValue(context, target)) - .extracting(SpelEvaluationException::getMessageCode).isEqualTo(SpelMessage.INDEXING_NOT_SUPPORTED_FOR_TYPE); + .extracting(SpelEvaluationException::getMessageCode).isEqualTo(SpelMessage.NOT_ASSIGNABLE); } @Test @@ -207,7 +207,7 @@ public class PropertyAccessTests extends AbstractExpressionTests { assertThatSpelEvaluationException() .isThrownBy(() -> parser.parseExpression("name='p3'").getValue(context, target2)) - .extracting(SpelEvaluationException::getMessageCode).isEqualTo(SpelMessage.PROPERTY_OR_FIELD_NOT_WRITABLE); + .extracting(SpelEvaluationException::getMessageCode).isEqualTo(SpelMessage.NOT_ASSIGNABLE); } @Test diff --git a/spring-expression/src/test/java/org/springframework/expression/spel/SpelCompilationCoverageTests.java b/spring-expression/src/test/java/org/springframework/expression/spel/SpelCompilationCoverageTests.java index 6667cc0cc0c..bf5ae88dbe6 100644 --- a/spring-expression/src/test/java/org/springframework/expression/spel/SpelCompilationCoverageTests.java +++ b/spring-expression/src/test/java/org/springframework/expression/spel/SpelCompilationCoverageTests.java @@ -5946,84 +5946,6 @@ public class SpelCompilationCoverageTests extends AbstractExpressionTests { } - static class CompilableMapAccessor implements CompilablePropertyAccessor { - - @Override - public boolean canRead(EvaluationContext context, Object target, String name) throws AccessException { - Map map = (Map) target; - return map.containsKey(name); - } - - @Override - public TypedValue read(EvaluationContext context, Object target, String name) throws AccessException { - Map map = (Map) target; - Object value = map.get(name); - if (value == null && !map.containsKey(name)) { - throw new MapAccessException(name); - } - return new TypedValue(value); - } - - @Override - public boolean canWrite(EvaluationContext context, Object target, String name) throws AccessException { - return true; - } - - @Override - @SuppressWarnings("unchecked") - public void write(EvaluationContext context, Object target, String name, Object newValue) throws AccessException { - Map map = (Map) target; - map.put(name, newValue); - } - - @Override - public Class[] getSpecificTargetClasses() { - return new Class[] {Map.class}; - } - - @Override - public boolean isCompilable() { - return true; - } - - @Override - public Class getPropertyType() { - return Object.class; - } - - @Override - public void generateCode(String propertyName, MethodVisitor mv, CodeFlow cf) { - String descriptor = cf.lastDescriptor(); - if (descriptor == null) { - cf.loadTarget(mv); - } - mv.visitLdcInsn(propertyName); - mv.visitMethodInsn(INVOKEINTERFACE, "java/util/Map", "get","(Ljava/lang/Object;)Ljava/lang/Object;",true); - } - } - - - /** - * Exception thrown from {@code read} in order to reset a cached - * PropertyAccessor, allowing other accessors to have a try. - */ - @SuppressWarnings("serial") - private static class MapAccessException extends AccessException { - - private final String key; - - public MapAccessException(String key) { - super(null); - this.key = key; - } - - @Override - public String getMessage() { - return "Map does not contain a value for key '" + this.key + "'"; - } - } - - public static class Greeter { public String getWorld() { diff --git a/spring-expression/src/test/java/org/springframework/expression/spel/support/SimpleEvaluationContextTests.java b/spring-expression/src/test/java/org/springframework/expression/spel/support/SimpleEvaluationContextTests.java new file mode 100644 index 00000000000..7ac2132883c --- /dev/null +++ b/spring-expression/src/test/java/org/springframework/expression/spel/support/SimpleEvaluationContextTests.java @@ -0,0 +1,477 @@ +/* + * Copyright 2002-2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.expression.spel.support; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.assertj.core.api.ThrowableTypeAssert; +import org.junit.jupiter.api.Test; + +import org.springframework.expression.Expression; +import org.springframework.expression.spel.CompilableMapAccessor; +import org.springframework.expression.spel.SpelEvaluationException; +import org.springframework.expression.spel.SpelMessage; +import org.springframework.expression.spel.standard.SpelExpressionParser; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.entry; + +/** + * Tests for {@link SimpleEvaluationContext}. + * + *

Some of the use cases in this test class are duplicated elsewhere within the test + * suite; however, we include them here to consistently focus on related features in this + * test class. + * + * @author Sam Brannen + */ +class SimpleEvaluationContextTests { + + private final SpelExpressionParser parser = new SpelExpressionParser(); + + private final Model model = new Model(); + + + @Test + void forReadWriteDataBinding() { + SimpleEvaluationContext context = SimpleEvaluationContext.forReadWriteDataBinding().build(); + + assertReadWriteMode(context); + } + + @Test + void forReadOnlyDataBinding() { + SimpleEvaluationContext context = SimpleEvaluationContext.forReadOnlyDataBinding().build(); + + assertCommonReadOnlyModeBehavior(context); + + // WRITE -- via assignment operator + + // Variable + assertAssignmentDisabled(context, "#myVar = 'rejected'"); + + // Property + assertAssignmentDisabled(context, "name = 'rejected'"); + assertIncrementDisabled(context, "count++"); + assertIncrementDisabled(context, "++count"); + assertDecrementDisabled(context, "count--"); + assertDecrementDisabled(context, "--count"); + + // Array Index + assertAssignmentDisabled(context, "array[0] = 'rejected'"); + assertIncrementDisabled(context, "numbers[0]++"); + assertIncrementDisabled(context, "++numbers[0]"); + assertDecrementDisabled(context, "numbers[0]--"); + assertDecrementDisabled(context, "--numbers[0]"); + + // List Index + assertAssignmentDisabled(context, "list[0] = 'rejected'"); + + // Map Index -- key as String + assertAssignmentDisabled(context, "map['red'] = 'rejected'"); + + // Map Index -- key as pseudo property name + assertAssignmentDisabled(context, "map[yellow] = 'rejected'"); + + // String Index + assertAssignmentDisabled(context, "name[0] = 'rejected'"); + + // Object Index + assertAssignmentDisabled(context, "['name'] = 'rejected'"); + } + + @Test + void forPropertyAccessorsInReadWriteMode() { + SimpleEvaluationContext context = SimpleEvaluationContext + .forPropertyAccessors(new CompilableMapAccessor(), DataBindingPropertyAccessor.forReadWriteAccess()) + .build(); + + assertReadWriteMode(context); + + // Map -- with key as property name supported by CompilableMapAccessor + + Expression expression; + expression = parser.parseExpression("map.yellow"); + expression.setValue(context, model, "pineapple"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("pineapple"); + + expression = parser.parseExpression("map.yellow = 'banana'"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("banana"); + expression = parser.parseExpression("map.yellow"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("banana"); + } + + /** + * We call this "mixed" read-only mode, because write access via PropertyAccessors is + * disabled, but write access via the Indexer is not disabled. + */ + @Test + void forPropertyAccessorsInMixedReadOnlyMode() { + SimpleEvaluationContext context = SimpleEvaluationContext + .forPropertyAccessors(new CompilableMapAccessor(), DataBindingPropertyAccessor.forReadOnlyAccess()) + .build(); + + assertCommonReadOnlyModeBehavior(context); + + // Map -- with key as property name supported by CompilableMapAccessor + + Expression expression; + expression = parser.parseExpression("map.yellow"); + expression.setValue(context, model, "pineapple"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("pineapple"); + + expression = parser.parseExpression("map.yellow = 'banana'"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("banana"); + expression = parser.parseExpression("map.yellow"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("banana"); + + // WRITE -- via assignment operator + + // Variable + assertThatSpelEvaluationException() + .isThrownBy(() -> parser.parseExpression("#myVar = 'rejected'").getValue(context, model)) + .satisfies(ex -> assertThat(ex.getMessageCode()).isEqualTo(SpelMessage.VARIABLE_ASSIGNMENT_NOT_SUPPORTED)); + + // Property + assertThatSpelEvaluationException() + .isThrownBy(() -> parser.parseExpression("name = 'rejected'").getValue(context, model)) + .satisfies(ex -> assertThat(ex.getMessageCode()).isEqualTo(SpelMessage.PROPERTY_OR_FIELD_NOT_WRITABLE)); + + // Array Index + parser.parseExpression("array[0]").setValue(context, model, "foo"); + assertThat(model.array).containsExactly("foo"); + + // List Index + parser.parseExpression("list[0]").setValue(context, model, "cat"); + assertThat(model.list).containsExactly("cat"); + + // Map Index -- key as String + parser.parseExpression("map['red']").setValue(context, model, "cherry"); + assertThat(model.map).containsOnly(entry("red", "cherry"), entry("yellow", "banana")); + + // Map Index -- key as pseudo property name + parser.parseExpression("map[yellow]").setValue(context, model, "lemon"); + assertThat(model.map).containsOnly(entry("red", "cherry"), entry("yellow", "lemon")); + + // String Index + // The Indexer does not support writes when indexing into a String. + assertThatSpelEvaluationException() + .isThrownBy(() -> parser.parseExpression("name[0] = 'rejected'").getValue(context, model)) + .satisfies(ex -> assertThat(ex.getMessageCode()).isEqualTo(SpelMessage.INDEXING_NOT_SUPPORTED_FOR_TYPE)); + + // Object Index + assertThatSpelEvaluationException() + .isThrownBy(() -> parser.parseExpression("['name'] = 'rejected'").getValue(context, model)) + .satisfies(ex -> assertThat(ex.getMessageCode()).isEqualTo(SpelMessage.INDEXING_NOT_SUPPORTED_FOR_TYPE)); + + // WRITE -- via increment and decrement operators + + assertIncrementAndDecrementWritesForIndexedStructures(context); + } + + + private void assertReadWriteMode(SimpleEvaluationContext context) { + // Variables can always be set programmatically within an EvaluationContext. + context.setVariable("myVar", "enigma"); + + // WRITE -- via setValue() + + // Property + parser.parseExpression("name").setValue(context, model, "test"); + assertThat(model.name).isEqualTo("test"); + parser.parseExpression("count").setValue(context, model, 42); + assertThat(model.count).isEqualTo(42); + + // Array Index + parser.parseExpression("array[0]").setValue(context, model, "foo"); + assertThat(model.array).containsExactly("foo"); + + // List Index + parser.parseExpression("list[0]").setValue(context, model, "cat"); + assertThat(model.list).containsExactly("cat"); + + // Map Index -- key as String + parser.parseExpression("map['red']").setValue(context, model, "cherry"); + assertThat(model.map).containsOnly(entry("red", "cherry"), entry("yellow", "replace me")); + + // Map Index -- key as pseudo property name + parser.parseExpression("map[yellow]").setValue(context, model, "lemon"); + assertThat(model.map).containsOnly(entry("red", "cherry"), entry("yellow", "lemon")); + + // READ + assertReadAccess(context); + + // WRITE -- via assignment operator + + // Variable assignment is always disabled in a SimpleEvaluationContext. + assertThatSpelEvaluationException() + .isThrownBy(() -> parser.parseExpression("#myVar = 'rejected'").getValue(context, model)) + .satisfies(ex -> assertThat(ex.getMessageCode()).isEqualTo(SpelMessage.VARIABLE_ASSIGNMENT_NOT_SUPPORTED)); + + Expression expression; + + // Property + expression = parser.parseExpression("name = 'changed'"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("changed"); + expression = parser.parseExpression("name"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("changed"); + + // Array Index + expression = parser.parseExpression("array[0] = 'bar'"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("bar"); + expression = parser.parseExpression("array[0]"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("bar"); + + // List Index + expression = parser.parseExpression("list[0] = 'dog'"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("dog"); + expression = parser.parseExpression("list[0]"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("dog"); + + // Map Index -- key as String + expression = parser.parseExpression("map['red'] = 'strawberry'"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("strawberry"); + expression = parser.parseExpression("map['red']"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("strawberry"); + + // Map Index -- key as pseudo property name + expression = parser.parseExpression("map[yellow] = 'banana'"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("banana"); + expression = parser.parseExpression("map[yellow]"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("banana"); + + // String Index + // The Indexer does not support writes when indexing into a String. + assertThatSpelEvaluationException() + .isThrownBy(() -> parser.parseExpression("name[0] = 'rejected'").getValue(context, model)) + .satisfies(ex -> assertThat(ex.getMessageCode()).isEqualTo(SpelMessage.INDEXING_NOT_SUPPORTED_FOR_TYPE)); + + // Object Index + expression = parser.parseExpression("['name'] = 'new name'"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("new name"); + expression = parser.parseExpression("['name']"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("new name"); + + // WRITE -- via increment and decrement operators + + assertIncrementAndDecrementWritesForProperties(context); + assertIncrementAndDecrementWritesForIndexedStructures(context); + } + + private void assertCommonReadOnlyModeBehavior(SimpleEvaluationContext context) { + // Variables can always be set programmatically within an EvaluationContext. + context.setVariable("myVar", "enigma"); + + // WRITE -- via setValue() + + // Note: forReadOnlyDataBinding() disables programmatic writes via setValue() for + // properties but allows programmatic writes via setValue() for indexed structures. + + // Property + assertThatSpelEvaluationException() + .isThrownBy(() -> parser.parseExpression("name").setValue(context, model, "test")) + .satisfies(ex -> assertThat(ex.getMessageCode()).isEqualTo(SpelMessage.PROPERTY_OR_FIELD_NOT_WRITABLE)); + assertThatSpelEvaluationException() + .isThrownBy(() -> parser.parseExpression("count").setValue(context, model, 42)) + .satisfies(ex -> assertThat(ex.getMessageCode()).isEqualTo(SpelMessage.PROPERTY_OR_FIELD_NOT_WRITABLE)); + + // Array Index + parser.parseExpression("array[0]").setValue(context, model, "foo"); + assertThat(model.array).containsExactly("foo"); + + // List Index + parser.parseExpression("list[0]").setValue(context, model, "cat"); + assertThat(model.list).containsExactly("cat"); + + // Map Index -- key as String + parser.parseExpression("map['red']").setValue(context, model, "cherry"); + assertThat(model.map).containsOnly(entry("red", "cherry"), entry("yellow", "replace me")); + + // Map Index -- key as pseudo property name + parser.parseExpression("map[yellow]").setValue(context, model, "lemon"); + assertThat(model.map).containsOnly(entry("red", "cherry"), entry("yellow", "lemon")); + + // Since the setValue() attempts for "name" and "count" failed above, we have to set + // them directly for assertReadAccess(). + model.name = "test"; + model.count = 42; + + // READ + assertReadAccess(context); + } + + private void assertReadAccess(SimpleEvaluationContext context) { + Expression expression; + + // Variable + expression = parser.parseExpression("#myVar"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("enigma"); + + // Property + expression = parser.parseExpression("name"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("test"); + expression = parser.parseExpression("count"); + assertThat(expression.getValue(context, model, Integer.class)).isEqualTo(42); + + // Array Index + expression = parser.parseExpression("array[0]"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("foo"); + + // List Index + expression = parser.parseExpression("list[0]"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("cat"); + + // Map Index -- key as String + expression = parser.parseExpression("map['red']"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("cherry"); + + // Map Index -- key as pseudo property name + expression = parser.parseExpression("map[yellow]"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("lemon"); + + // String Index + expression = parser.parseExpression("name[0]"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("t"); + + // Object Index + expression = parser.parseExpression("['name']"); + assertThat(expression.getValue(context, model, String.class)).isEqualTo("test"); + } + + private void assertIncrementAndDecrementWritesForProperties(SimpleEvaluationContext context) { + Expression expression; + expression = parser.parseExpression("count++"); + assertThat(expression.getValue(context, model, Integer.class)).isEqualTo(42); + expression = parser.parseExpression("count"); + assertThat(expression.getValue(context, model, Integer.class)).isEqualTo(43); + + expression = parser.parseExpression("++count"); + assertThat(expression.getValue(context, model, Integer.class)).isEqualTo(44); + expression = parser.parseExpression("count"); + assertThat(expression.getValue(context, model, Integer.class)).isEqualTo(44); + + expression = parser.parseExpression("count--"); + assertThat(expression.getValue(context, model, Integer.class)).isEqualTo(44); + expression = parser.parseExpression("count"); + assertThat(expression.getValue(context, model, Integer.class)).isEqualTo(43); + + expression = parser.parseExpression("--count"); + assertThat(expression.getValue(context, model, Integer.class)).isEqualTo(42); + expression = parser.parseExpression("count"); + assertThat(expression.getValue(context, model, Integer.class)).isEqualTo(42); + } + + private void assertIncrementAndDecrementWritesForIndexedStructures(SimpleEvaluationContext context) { + Expression expression; + expression = parser.parseExpression("numbers[0]++"); + assertThat(expression.getValue(context, model, Integer.class)).isEqualTo(99); + expression = parser.parseExpression("numbers[0]"); + assertThat(expression.getValue(context, model, Integer.class)).isEqualTo(100); + + expression = parser.parseExpression("++numbers[0]"); + assertThat(expression.getValue(context, model, Integer.class)).isEqualTo(101); + expression = parser.parseExpression("numbers[0]"); + assertThat(expression.getValue(context, model, Integer.class)).isEqualTo(101); + + expression = parser.parseExpression("numbers[0]--"); + assertThat(expression.getValue(context, model, Integer.class)).isEqualTo(101); + expression = parser.parseExpression("numbers[0]"); + assertThat(expression.getValue(context, model, Integer.class)).isEqualTo(100); + + expression = parser.parseExpression("--numbers[0]"); + assertThat(expression.getValue(context, model, Integer.class)).isEqualTo(99); + expression = parser.parseExpression("numbers[0]"); + assertThat(expression.getValue(context, model, Integer.class)).isEqualTo(99); + } + + private ThrowableTypeAssert assertThatSpelEvaluationException() { + return assertThatExceptionOfType(SpelEvaluationException.class); + } + + private void assertAssignmentDisabled(SimpleEvaluationContext context, String expression) { + assertEvaluationException(context, expression, SpelMessage.NOT_ASSIGNABLE); + } + + private void assertIncrementDisabled(SimpleEvaluationContext context, String expression) { + assertEvaluationException(context, expression, SpelMessage.OPERAND_NOT_INCREMENTABLE); + } + + private void assertDecrementDisabled(SimpleEvaluationContext context, String expression) { + assertEvaluationException(context, expression, SpelMessage.OPERAND_NOT_DECREMENTABLE); + } + + private void assertEvaluationException(SimpleEvaluationContext context, String expression, SpelMessage spelMessage) { + assertThatSpelEvaluationException() + .isThrownBy(() -> parser.parseExpression(expression).getValue(context, model)) + .satisfies(ex -> assertThat(ex.getMessageCode()).isEqualTo(spelMessage)); + } + + + static class Model { + + private String name = "replace me"; + private int count = 0; + private final String[] array = {"replace me"}; + private final int[] numbers = {99}; + private final List list = new ArrayList<>(); + private final Map map = new HashMap<>(); + + Model() { + this.list.add("replace me"); + this.map.put("red", "replace me"); + this.map.put("yellow", "replace me"); + } + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + public int getCount() { + return this.count; + } + + public void setCount(int count) { + this.count = count; + } + + public String[] getArray() { + return this.array; + } + + public int[] getNumbers() { + return this.numbers; + } + + public List getList() { + return this.list; + } + + public Map getMap() { + return this.map; + } + + } + +}