Ensure SpEL's InlineList is immutable in compiled mode

Prior to this commit, SpEL's InlineList was cached as a mutable list in
compiled mode (i.e., in a static field in the generated byte code).

To address that, this commit modifies InlineList's generateClinitCode()
method so that it wraps both top-level and nested inline lists using
Collections.unmodifiableList(), analogous to what we already do in
createList().

Closes gh-37001
This commit is contained in:
Sam Brannen
2026-07-06 10:52:54 +02:00
parent d1470bbb25
commit f0e69a702c
2 changed files with 65 additions and 3 deletions
@@ -176,9 +176,10 @@ public class InlineList extends SpelNodeImpl {
else {
mv.visitInsn(DUP);
}
// The children might be further lists if they are not constants. In this
// situation do not call back into generateCode() because it will register another clinit adder.
// Instead, directly build the list here:
// Nested InlineList children are always constant (guaranteed by isCompilable/isConstant).
// Thus, we call generateClinitCode() directly rather than generateCode() to avoid registering
// a separate static field and clinit entry for each nested list. In other words, we build each
// nested list inline within the current clinit sequence.
if (this.children[c] instanceof InlineList inlineList) {
inlineList.generateClinitCode(clazzname, constantFieldName, mv, codeflow, true);
}
@@ -192,6 +193,18 @@ public class InlineList extends SpelNodeImpl {
mv.visitMethodInsn(INVOKEINTERFACE, "java/util/List", "add", "(Ljava/lang/Object;)Z", true);
mv.visitInsn(POP);
}
// Wrap the mutable ArrayList in an unmodifiable list, matching the behavior
// of the interpreted mode (see createList()). For the non-nested case, retrieve
// the list from the static field first, then store the wrapped list back. For
// the nested case, the list is already on the stack for the caller to use.
if (!nested) {
mv.visitFieldInsn(GETSTATIC, clazzname, constantFieldName, "Ljava/util/List;");
}
mv.visitMethodInsn(INVOKESTATIC, "java/util/Collections", "unmodifiableList",
"(Ljava/util/List;)Ljava/util/List;", false);
if (!nested) {
mv.visitFieldInsn(PUTSTATIC, clazzname, constantFieldName, "Ljava/util/List;");
}
}
}
@@ -1619,6 +1619,55 @@ public class SpelCompilationCoverageTests extends AbstractExpressionTests {
o = expression.getValue();
assertThat(o).isEqualTo("op");
}
@Test // gh-37001
@SuppressWarnings("unchecked")
void compiledInlineListIsUnmodifiable() {
expression = parser.parseExpression("{1, 2, 3}");
List<Object> interpreted = (List<Object>) expression.getValue();
assertThat(interpreted).containsExactly(1, 2, 3);
// Interpreted --> unmodifiable
assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(() -> interpreted.add("boom"));
assertCanCompile(expression);
List<Object> compiled = (List<Object>) expression.getValue();
assertThat(compiled).containsExactly(1, 2, 3);
// Compiled --> unmodifiable
assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(() -> compiled.add("boom"));
// Same list reference must be returned on every evaluation
assertThat(expression.getValue()).isSameAs(compiled);
}
@Test // gh-37001
@SuppressWarnings("unchecked")
void compiledNestedInlineListsAreUnmodifiable() {
expression = parser.parseExpression("{{1, 2}, {3, 4}}");
List<Object> interpreted = (List<Object>) expression.getValue();
assertThat(interpreted.toString()).isEqualTo("[[1, 2], [3, 4]]");
// Interpreted --> unmodifiable
assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(() -> interpreted.add("boom"));
List<Object> nested1Interpreted = (List<Object>) interpreted.get(0);
List<Object> nested2Interpreted = (List<Object>) interpreted.get(1);
assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(() -> nested1Interpreted.add("boom"));
assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(() -> nested2Interpreted.add("boom"));
assertCanCompile(expression);
List<Object> compiled = (List<Object>) expression.getValue();
assertThat(compiled.toString()).isEqualTo("[[1, 2], [3, 4]]");
// Compiled --> unmodifiable
assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(() -> compiled.add("boom"));
List<Object> nested1Compiled = (List<Object>) compiled.get(0);
List<Object> nested2Compiled = (List<Object>) compiled.get(1);
assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(() -> nested1Compiled.add("boom"));
assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(() -> nested2Compiled.add("boom"));
}
}
@Nested