Skip to content

Commit e8cf402

Browse files
Reject stale refinements after short-circuit RHS writes
Port the short-circuit soundness fix and reproducer from closed PR #257 to current main. Add coverage for OR, nested assignments, increments, and an RHS without writes. Fixes #323 Co-authored-by: Guilherme Espada <gjespada@fc.ul.pt>
1 parent dd02e99 commit e8cf402

2 files changed

Lines changed: 93 additions & 0 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package testSuite;
2+
3+
import liquidjava.specification.Refinement;
4+
5+
// SOUNDNESS HOLE: assignments on a not-taken control-flow path are applied to the abstract
6+
// state. In `false && ((x = 1) == 1)` the right operand never executes (short-circuit), so x stays
7+
// 0 at runtime, but the verifier records x = 1 and ACCEPTS "_ == 1". Should be rejected.
8+
@SuppressWarnings("unused")
9+
public class ErrorShortCircuitAssignUnsound {
10+
public static void main(String[] args) {
11+
int x = 0;
12+
boolean b = false && ((x = 1) == 1);
13+
@Refinement("_ == 1")
14+
int y = x; // Expect: Refinement Error
15+
// runtime check mirrors the refinement; aborts under -ea because y == 0
16+
assert y == 1 : "y=" + y;
17+
}
18+
19+
public static void skippedOr() {
20+
int x = 0;
21+
boolean b = true || ((x = 1) == 1);
22+
@Refinement("_ == 1")
23+
int y = x; // Expect: Refinement Error
24+
}
25+
26+
public static void nestedRightOperand() {
27+
int x = 0;
28+
boolean b = false && ((x = 1) == 1 && (x = 2) == 2);
29+
@Refinement("_ == 2")
30+
int y = x; // Expect: Refinement Error
31+
}
32+
33+
public static void incrementInRightOperand() {
34+
int x = 0;
35+
boolean b = false && (++x > 0);
36+
@Refinement("_ == 1")
37+
int y = x; // Expect: Refinement Error
38+
}
39+
40+
public static void noRightOperandWrite() {
41+
int x = 0;
42+
boolean b = false && (x == 1);
43+
@Refinement("_ == 0")
44+
int y = x;
45+
}
46+
}

‎liquidjava-verifier/src/main/java/liquidjava/processor/refinement_checker/RefinementTypeChecker.java‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import liquidjava.utils.constants.Types;
2121

2222
import org.apache.commons.lang3.NotImplementedException;
23+
import spoon.reflect.code.BinaryOperatorKind;
2324
import spoon.reflect.code.CtArrayRead;
2425
import spoon.reflect.code.CtArrayWrite;
2526
import spoon.reflect.code.CtAssignment;
@@ -46,11 +47,13 @@
4647
import spoon.reflect.code.CtUnaryOperator;
4748
import spoon.reflect.code.CtVariableAccess;
4849
import spoon.reflect.code.CtVariableRead;
50+
import spoon.reflect.code.CtVariableWrite;
4951
import spoon.reflect.declaration.*;
5052
import spoon.reflect.factory.Factory;
5153
import spoon.reflect.reference.CtFieldReference;
5254
import spoon.reflect.reference.CtTypeReference;
5355
import spoon.reflect.reference.CtVariableReference;
56+
import spoon.reflect.visitor.filter.TypeFilter;
5457
import spoon.support.reflect.code.CtVariableWriteImpl;
5558

5659
public class RefinementTypeChecker extends TypeChecker {
@@ -346,6 +349,50 @@ public <T> void visitCtVariableRead(CtVariableRead<T> variableRead) {
346349
public <T> void visitCtBinaryOperator(CtBinaryOperator<T> operator) {
347350
super.visitCtBinaryOperator(operator);
348351
otc.getBinaryOpRefinements(operator);
352+
forgetShortCircuitedAssignments(operator);
353+
}
354+
355+
/**
356+
* The right operand of {@code &&}/{@code ||} runs only conditionally (it is short-circuited when the left operand
357+
* is already {@code false} resp. {@code true}). Spoon visits children before this method, so any assignment in that
358+
* operand (e.g. {@code false && ((x = 1) == 1)}) has already committed its value to the context as if it always
359+
* executed. That is unsound: at runtime the assignment may never happen, so the post-operator value of every
360+
* variable written there is uncertain. Havoc those variables (give them a fresh, unconstrained instance) so the
361+
* verifier can no longer assume the assigned value survives the operator.
362+
*
363+
* <p>
364+
* This is conservative: when the left operand is statically true (resp. false) the right operand does execute, yet
365+
* we still forget the value. Forgetting only ever weakens what is known, so it cannot accept an unsound program; it
366+
* costs precision only for the rare idiom of relying on a value assigned inside a short-circuited operand.
367+
*/
368+
private void forgetShortCircuitedAssignments(CtBinaryOperator<?> operator) {
369+
BinaryOperatorKind kind = operator.getKind();
370+
if (kind != BinaryOperatorKind.AND && kind != BinaryOperatorKind.OR)
371+
return;
372+
373+
CtExpression<?> conditionalOperand = operator.getRightHandOperand();
374+
for (CtVariableWrite<?> write : conditionalOperand.getElements(new TypeFilter<>(CtVariableWrite.class))) {
375+
CtVariableReference<?> ref = write.getVariable();
376+
if (ref == null)
377+
continue;
378+
String name = (write instanceof CtFieldWrite<?>) ? String.format(Formats.THIS, ref.getSimpleName())
379+
: ref.getSimpleName();
380+
havocVariable(name, write);
381+
}
382+
}
383+
384+
/**
385+
* Drops everything currently known about {@code name} by installing a fresh, unconstrained instance as its latest
386+
* value. Subsequent reads resolve to this instance and therefore carry no refinement.
387+
*/
388+
private void havocVariable(String name, CtElement element) {
389+
RefinedVariable rv = context.getVariableByName(name);
390+
if (!(rv instanceof Variable))
391+
return;
392+
String freshName = String.format(Formats.INSTANCE, name, context.getCounter());
393+
context.addInstanceToContext(freshName, rv.getType(), new Predicate(), element);
394+
context.addRefinementInstanceToVariable(name, freshName);
395+
context.addRefinementToVariableInContext(name, rv.getType(), new Predicate(), element);
349396
}
350397

351398
@Override

0 commit comments

Comments
 (0)