Skip to content

Commit cb2864d

Browse files
committed
GROOVY-12276: Extend the closure cycle check to the closures a wrapper calls
Closure.checkForReferenceCycle walks owner, delegate and thisObject, which are the fields a closure dispatches through. A closure which wraps another and invokes it recurses through its own field instead, so a forged graph cycling there passed the check and then exhausted the stack on invocation. Measured before the change, building each cycle by reflection, serializing and reading it back: CurriedClosure, cycle via owner rejected, InvalidObjectException ComposedClosure, cycle via first/second accepted, then StackOverflowError TrampolineClosure, cycle via original accepted, then StackOverflowError ComposedClosure was the more surprising of the two: it already opts into the check from its readResolve, so the check ran and had nothing to say about the closure's own recursion. TrampolineClosure had no readResolve at all. Add Closure.additionalReferences for a subclass to declare the closures it calls through, and walk those as well. ComposedClosure declares first and second; TrampolineClosure declares original and gains the readResolve it was missing. Only fields a closure calls belong there, which is why the walk is not simply made reflective over every Closure-valued field: a captured closure is not a recursion edge, and treating it as one would reject graphs that invoke perfectly well. The existing ComposedClosure cycle test forges its cycle through owner and delegate, so it exercised the walk that already worked; the new tests forge through the wrapped fields, and fail without this change.
1 parent 389ab8c commit cb2864d

4 files changed

Lines changed: 145 additions & 2 deletions

File tree

src/main/java/groovy/lang/Closure.java

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
import java.util.IdentityHashMap;
5757
import java.util.List;
5858
import java.util.Map;
59+
import java.util.Objects;
5960
import java.util.Set;
6061
import java.util.function.BiConsumer;
6162
import java.util.function.Function;
@@ -1466,11 +1467,14 @@ protected static void checkForReferenceCycle(final Closure<?> root) throws Inval
14661467
}
14671468
grey.add(node);
14681469
stack.push(new Marker(node));
1469-
for (final Object link : new Object[]{node.owner, node.delegate, node.thisObject}) {
1470+
final Object[] extra = Objects.requireNonNull(node.additionalReferences(),
1471+
() -> node.getClass().getName() + ".additionalReferences() must not return null");
1472+
final Object[] links = concat(new Object[]{node.owner, node.delegate, node.thisObject}, extra);
1473+
for (final Object link : links) {
14701474
if (link instanceof Closure<?> child) {
14711475
if (grey.contains(child)) {
14721476
throw new InvalidObjectException(
1473-
"Closure owner/delegate/thisObject references form a cycle; refusing to deserialize");
1477+
"Closure references form a cycle; refusing to deserialize");
14741478
}
14751479
if (!black.contains(child)) {
14761480
stack.push(child);
@@ -1480,6 +1484,33 @@ protected static void checkForReferenceCycle(final Closure<?> root) throws Inval
14801484
}
14811485
}
14821486

1487+
private static Object[] concat(final Object[] first, final Object[] second) {
1488+
if (second.length == 0) return first;
1489+
final Object[] joined = new Object[first.length + second.length];
1490+
System.arraycopy(first, 0, joined, 0, first.length);
1491+
System.arraycopy(second, 0, joined, first.length, second.length);
1492+
return joined;
1493+
}
1494+
1495+
/**
1496+
* The closures this one calls through, beyond {@code owner}, {@code delegate} and
1497+
* {@code thisObject}, for the purposes of {@link #checkForReferenceCycle}.
1498+
* <p>
1499+
* A closure which wraps another and invokes it, as
1500+
* {@link org.codehaus.groovy.runtime.ComposedClosure} and {@code TrampolineClosure} do,
1501+
* recurses through its own fields rather than through the three the cycle check walks by
1502+
* default, so a forged graph would escape the check unless it declares them here.
1503+
* <p>
1504+
* Only fields the closure <em>calls</em> belong here. A merely captured closure is not a
1505+
* recursion edge, and declaring one would reject graphs which invoke perfectly well.
1506+
*
1507+
* @return the additional closures reached when this one is invoked; never {@code null}
1508+
* @since 6.0.0
1509+
*/
1510+
protected Object[] additionalReferences() {
1511+
return EMPTY_OBJECT_ARRAY;
1512+
}
1513+
14831514
/** Sentinel pushed below a node's children during the {@link #checkForReferenceCycle} cycle check. */
14841515
private static final class Marker {
14851516
final Closure<?> closure;

src/main/java/groovy/lang/TrampolineClosure.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
*/
1919
package groovy.lang;
2020

21+
import java.io.ObjectStreamException;
2122
import java.io.Serial;
2223

2324
/**
@@ -43,6 +44,25 @@ final class TrampolineClosure<V> extends Closure<V> {
4344
this.original = original;
4445
}
4546

47+
/**
48+
* A trampoline calls through the closure it wraps, so {@code original} is a recursion edge
49+
* in addition to the three the cycle check walks by default.
50+
*/
51+
@Override
52+
protected Object[] additionalReferences() {
53+
return new Object[]{original};
54+
}
55+
56+
/**
57+
* Rejects a deserialized trampoline whose references form a cycle, which would otherwise
58+
* recurse indefinitely on invocation. See {@link Closure#checkForReferenceCycle}.
59+
*/
60+
@Serial
61+
private Object readResolve() throws ObjectStreamException {
62+
Closure.checkForReferenceCycle(this);
63+
return this;
64+
}
65+
4666
/**
4767
* Delegates to the wrapped closure
4868
*/

src/main/java/org/codehaus/groovy/runtime/ComposedClosure.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,4 +129,13 @@ private Object readResolve() throws ObjectStreamException {
129129
Closure.checkForReferenceCycle(this);
130130
return this;
131131
}
132+
133+
/**
134+
* A composed closure calls through {@code first} and then {@code second}, so those are
135+
* recursion edges in addition to the three the cycle check walks by default.
136+
*/
137+
@Override
138+
protected Object[] additionalReferences() {
139+
return new Object[]{first, second};
140+
}
132141
}

src/test/groovy/groovy/lang/ClosureSerializationCycleTest.groovy

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,22 @@ final class ClosureSerializationCycleTest {
4545
field.set(target, value)
4646
}
4747

48+
/** Sets a field declared anywhere in the target's hierarchy, not just on Closure. */
49+
private static void setDeclaredField(Object target, String name, Object value) {
50+
Class<?> c = target.getClass()
51+
while (c != null) {
52+
try {
53+
def field = c.getDeclaredField(name)
54+
field.accessible = true
55+
field.set(target, value)
56+
return
57+
} catch (NoSuchFieldException ignored) {
58+
c = c.superclass
59+
}
60+
}
61+
throw new NoSuchFieldException(name)
62+
}
63+
4864
private static byte[] serialize(Object obj) {
4965
def out = new ByteArrayOutputStream()
5066
out.withObjectOutputStream { it.writeObject(obj) }
@@ -96,6 +112,41 @@ final class ClosureSerializationCycleTest {
96112
assert err.message.contains('cycle')
97113
}
98114

115+
@Test
116+
void testComposedClosureCycleThroughItsOwnFieldsRejected() {
117+
// The wrapped closures a ComposedClosure calls through are its own first/second fields,
118+
// not owner/delegate/thisObject, so a cycle formed there is a different graph from the
119+
// one above and would otherwise pass the check and then recurse on invocation.
120+
byte[] bytes = Holder.serializeComposedCyclicThroughWrappedFields()
121+
def err = shouldFail(InvalidObjectException) { deserialize(bytes) }
122+
assert err.message.contains('cycle')
123+
}
124+
125+
@Test
126+
void testTrampolineClosureCycleRejected() {
127+
// TrampolineClosure calls through its original field, and had no readResolve at all.
128+
byte[] bytes = Holder.serializeCyclicTrampoline()
129+
def err = shouldFail(InvalidObjectException) { deserialize(bytes) }
130+
assert err.message.contains('cycle')
131+
}
132+
133+
@Test
134+
void testNullAdditionalReferencesFailsClosed() {
135+
// a broken override must abort deserialization with a diagnostic naming the subclass,
136+
// not silently drop its recursion edges from the cycle check
137+
byte[] bytes = Holder.serializeCurriedOverNullRefsClosure()
138+
def err = shouldFail(NullPointerException) { deserialize(bytes) }
139+
assert err.message.contains('NullRefsClosure')
140+
assert err.message.contains('additionalReferences() must not return null')
141+
}
142+
143+
@Test
144+
void testLegitimateTrampolineRoundTrips() {
145+
byte[] bytes = Holder.serializeTrampoline()
146+
def t = deserialize(bytes)
147+
assert t.call(5) == 10
148+
}
149+
99150
@Test
100151
void testLegitimateComposedClosureRoundTrips() {
101152
byte[] bytes = Holder.serializeComposed()
@@ -120,6 +171,16 @@ final class ClosureSerializationCycleTest {
120171
assert c.call('y') == 'x-y'
121172
}
122173

174+
/** A buggy subclass whose override violates the never-null contract of additionalReferences. */
175+
static final class NullRefsClosure extends Closure<Object> {
176+
NullRefsClosure(Object owner) { super(owner) }
177+
178+
@Override
179+
protected Object[] additionalReferences() { null }
180+
181+
Object doCall(Object x) { x }
182+
}
183+
123184
static class Holder {
124185
static byte[] serializeGreeter() {
125186
serialize({ p -> "Hello, $p" })
@@ -136,6 +197,28 @@ final class ClosureSerializationCycleTest {
136197
serialize(inc >> twice)
137198
}
138199

200+
static byte[] serializeComposedCyclicThroughWrappedFields() {
201+
def composed = ({ x -> x } >> { y -> y })
202+
setDeclaredField(composed, 'first', composed)
203+
setDeclaredField(composed, 'second', composed)
204+
serialize(composed)
205+
}
206+
207+
static byte[] serializeTrampoline() {
208+
def base = { a -> a * 2 }
209+
serialize(base.trampoline())
210+
}
211+
212+
static byte[] serializeCyclicTrampoline() {
213+
def trampoline = { x -> x }.trampoline()
214+
setDeclaredField(trampoline, 'original', trampoline)
215+
serialize(trampoline)
216+
}
217+
218+
static byte[] serializeCurriedOverNullRefsClosure() {
219+
serialize(new CurriedClosure(new NullRefsClosure('s'), 'x'))
220+
}
221+
139222
static byte[] serializeCyclicComposed() {
140223
// built in a static context so the wrapped closures' owner is the (serializable)
141224
// class rather than the test instance, then the owner/delegate are made self-referential

0 commit comments

Comments
 (0)