Example: Typestate Analysis with Heros
The problem
Many APIs have an unwritten rule about the order in which their methods may be called.
A file handle must be opened before it is written to. An Iterator must not be advanced
after the collection was modified. A Cipher must be initialised before it encrypts.
The Java type system does not express any of this: every one of those calls compiles.
A typestate is the missing piece of information — not "what type is this object?"
but "what state is this object in, right now, at this program point?". A typestate
analysis attaches a small finite automaton to an API class, and then checks that every
object of that class only ever sees call sequences the automaton accepts.
Two things make this harder than the analyses in
Live Variable Analysis and
Constant Propagation:
- The object usually travels across method boundaries, so the analysis has to be
interprocedural.
- The analysis has to track which objects exist and, for each of them, which state it
is in. That is two different kinds of information at once.
IDE — the framework Heros implements
and SootUp binds to — is built exactly for that shape of problem. This page configures it
step by step. Every snippet is taken from
sootup.examples/src/test/java/sootup/examples/typestate, which is compiled and run by
CI, so what you read here is what actually executes.
Step 1 — The target program
The API whose protocol we want to enforce:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24 | /**
* A tiny API with a usage protocol: a handle must be opened before it is written to,
* and it must be closed afterwards.
*
* <p>Nothing in the Java type system enforces that order. The typestate analysis in
* {@code sootup.examples.typestate} does.
*/
public class FileHandle {
/** Moves the handle from CLOSED to OPEN. */
public void open() {
// no-op: only the call sequence matters for the analysis
}
/** Legal only while the handle is OPEN. */
public void write(String data) {
// no-op
}
/** Moves the handle from OPEN back to CLOSED. */
public void close() {
// no-op
}
}
|
And the program using it:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25 | /**
* Two uses of the {@link FileHandle} protocol: one that respects it and one that does not.
*
* <p>{@code correctUsage} additionally performs the {@code write} inside a second method,
* so the analysis only reaches the right answer if it follows the call.
*/
public class Example {
public static void correctUsage() {
FileHandle handle = new FileHandle(); // state: CLOSED
handle.open(); // state: OPEN
writeGreeting(handle); // state: OPEN (write happens in the callee)
handle.close(); // state: CLOSED
}
public static void protocolViolation() {
FileHandle handle = new FileHandle(); // state: CLOSED
handle.write("nothing is open yet"); // no CLOSED --write--> transition: ERROR
handle.close(); // still ERROR
}
private static void writeGreeting(FileHandle handle) {
handle.write("hello");
}
}
|
Note that correctUsage performs the write inside a second method. An
intraprocedural analysis would simply not see it.
Step 2 — The protocol as an automaton
The rule "open, then write as often as you like, then close" is a two-state automaton:
| open
┌─────────────────────────┐
│ ▼
╔═════════════╗ ┌───────────┐ ──┐
║ CLOSED ║ │ OPEN │ │ write
╚═════════════╝ └───────────┘ ◄─┘
▲ │
└─────────────────────────┘
close
╔═╗ initial state, and the only accepting state
|
| From |
Event |
To |
CLOSED |
open |
OPEN |
OPEN |
write |
OPEN |
OPEN |
close |
CLOSED |
Every transition not in that table is a violation. CLOSED --write--> ? has no entry, so
calling write on a closed handle is an error.
Written with the small Typestate helper from the example:
1
2
3
4
5
6
7
8
9
10
11
12
13
14 | /** CLOSED --open--> OPEN --write--> OPEN --close--> CLOSED */
static Typestate fileHandleProtocol() {
Typestate.Builder builder = Typestate.builder();
int closed = builder.addState("CLOSED");
int open = builder.addState("OPEN");
builder.setInitialState(closed);
builder.addTransition(closed, "open", open);
builder.addTransition(open, "write", open);
builder.addTransition(open, "close", closed);
builder.setAccepting(closed, true);
return builder.build();
}
|
States are plain ints on purpose — a state has to fit inside the value the solver
propagates, and it has to be cheap to compare:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28 | public int getStateCount() {
return names.length;
}
public String getStateName(int state) {
return names[state];
}
public int getInitialState() {
return initialState;
}
/**
* Applies {@code event} — the name of the method that was just called — to {@code state}.
*
* @return the successor state, or {@link #INVALID_STATE} if the call is not allowed here.
*/
public int trigger(int state, String event) {
if (state < 0 || state >= transitions.size()) {
return INVALID_STATE;
}
Integer target = transitions.get(state).get(event);
return target == null ? INVALID_STATE : target;
}
public boolean isAccepting(int state) {
return state >= 0 && state < accepting.length && accepting[state];
}
|
Step 3 — Why IFDS is not enough, and what IDE adds
IFDS turns an interprocedural analysis into a graph reachability
problem over an exploded supergraph. Each node of that
graph is a pair (statement, fact), and the analysis question becomes: which pairs are
reachable from the starting pair?
That answers "is this local one of the handles I care about?" — a yes/no question. It
cannot answer "and which state is it in?", because the only thing IFDS computes is
reachability.
IDE adds a second dimension. Every edge of the exploded supergraph
additionally carries an edge function: a function that
transforms a value as the fact flows along that edge. So the analysis now computes two
things at every program point — the set of facts that reach it, and for each fact a value.
For a typestate analysis the split is natural:
| IDE element |
Our choice |
Meaning |
Facts D |
Jimple Value |
the locals that currently point at a tracked object |
Values V |
TypestateFact |
the automaton state that object is in |
| Zero fact |
an artificial local <<zero>> |
always reachable; new facts are generated out of it |
| Flow functions |
four methods |
which locals to track across a statement |
| Edge functions |
four methods |
how the state changes along that same step |
| Meet lattice |
TOP / states / ERROR / BOTTOM |
how to combine values where paths join |
The one sentence to remember
Flow functions decide which facts survive a statement. Edge functions decide
what value each surviving fact carries. Both are asked for every statement, and
for the same four kinds of edge.
Step 4 — Dependencies
The Heros binding lives in its own module.
It brings Heros itself in transitively. The two classes you will subclass or instantiate
are DefaultJimpleIDETabulationProblem (your problem description) and JimpleIDESolver
(the solver that runs it).
Step 5 — The value type
TypestateFact is the V of the problem. Besides the automaton's own states it needs
three special values, and Heros gives each of them a specific job:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36 | public final class TypestateFact {
static final int CODE_ERROR = -1;
static final int CODE_TOP = -2;
static final int CODE_BOTTOM = -3;
/** No information yet. The IDE solver starts every value at top. */
public static final TypestateFact TOP = new TypestateFact(null, CODE_TOP);
/** Two execution paths reach this point in different states. */
public static final TypestateFact BOTTOM = new TypestateFact(null, CODE_BOTTOM);
/** A call was made that the automaton does not allow — the protocol was violated. */
public static final TypestateFact ERROR = new TypestateFact(null, CODE_ERROR);
private final Typestate automaton;
private final int code;
private TypestateFact(Typestate automaton, int code) {
this.automaton = automaton;
this.code = code;
}
/** Wraps a state of {@code automaton}; invalid states collapse to {@link #ERROR}. */
public static TypestateFact of(Typestate automaton, int code) {
switch (code) {
case CODE_TOP:
return TOP;
case CODE_BOTTOM:
return BOTTOM;
case CODE_ERROR:
return ERROR;
default:
return new TypestateFact(automaton, code);
}
}
|
TOP is the neutral element of the meet. The solver starts
every value at top and never stores it, so "top" and "no entry in the result map" are
the same thing.
BOTTOM is the absorbing element: two paths reach this point with the object in
different states and the analysis cannot commit to either.
ERROR is ours, not something Heros requires. It records that a call happened for
which the automaton has no transition — that is the bug we are looking for.
Step 6 — The problem class
Everything else is one class. It extends the SootUp template, which fixes the node type to
Stmt and the method type to SootMethod for you:
| public class TypestateProblem
extends DefaultJimpleIDETabulationProblem<
Value, TypestateFact, InterproceduralCFG<Stmt, SootMethod>> {
|
The template asks for six things: a zero value, the initial seeds, an all-top function, a
meet lattice, a flow function factory and an edge function factory. The next steps fill
them in one by one.
Step 7 — The zero fact and the seeds
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 | /**
* The artificial "zero" fact. It is always reachable, so it is the fact that other facts are
* generated out of — see {@link #getNormalFlow} below, where the tracked local is generated from
* the zero fact at the {@code new} statement.
*/
@Override
protected Value createZeroValue() {
return new Local("<<zero>>", NullType.getInstance());
}
/** Where the analysis starts: the zero fact, at the first statement of the entry method. */
@Override
public Map<Stmt, Set<Value>> initialSeeds() {
return DefaultSeeds.make(
Collections.singleton(entryMethod.getBody().getControlFlowGraph().getStartingStmt()),
zeroValue());
}
|
The zero fact is a fact that is reachable at every statement
by construction. It is not a variable of the program; it is a placeholder that means
"control flow gets here". It matters because IFDS and IDE can only ever propagate facts,
never invent them: a new fact has to be generated from a fact that already holds — and the
zero fact is the one that always holds.
The seeds say where analysis begins: here, the zero fact at the
first statement of the entry method. DefaultSeeds.make is a Heros convenience for the
common "one fact, a set of start statements" case.
The zero fact does not carry top
The solver sets the value of the zero fact at a seed to the lattice's bottom
element, not top. That is why the edge function that creates an object (Step 10) must
be a constant function — it has to produce the initial state no matter what came in.
Deriving the state from the incoming value would produce BOTTOM for every object.
Step 8 — The meet lattice
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40 | /** Before anything is known, every value is top. */
@Override
protected EdgeFunction<TypestateFact> createAllTopFunction() {
return new AllTop<>(TypestateFact.TOP);
}
@Override
protected MeetLattice<TypestateFact> createMeetLattice() {
return new MeetLattice<TypestateFact>() {
@Override
public TypestateFact topElement() {
return TypestateFact.TOP;
}
@Override
public TypestateFact bottomElement() {
return TypestateFact.BOTTOM;
}
@Override
public TypestateFact meet(TypestateFact left, TypestateFact right) {
if (left.equals(right)) {
return left;
}
if (left == TypestateFact.TOP) {
return right;
}
if (right == TypestateFact.TOP) {
return left;
}
// a violation on one path is a violation, even if another path is fine
if (left == TypestateFact.ERROR || right == TypestateFact.ERROR) {
return TypestateFact.ERROR;
}
// two different states: the analysis cannot say which one holds here
return TypestateFact.BOTTOM;
}
};
}
|
meet is applied wherever paths join. Two paths that agree keep their state; if one of
them says nothing (TOP) the other one wins; if one of them found a violation the
violation wins, because a bug on one path is still a bug; and if two paths genuinely
disagree — say one leaves the handle OPEN and the other CLOSED — the result is
BOTTOM, meaning "no single state describes this point".
createAllTopFunction returns the function that maps everything to TOP. Heros uses it
to initialise its jump function table, i.e. as the value of an edge nothing is known about
yet.
Step 9 — Flow functions: which locals to track
Heros asks for four flow functions, one per kind of edge in the exploded supergraph:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26 | @Override
protected FlowFunctions<Stmt, Value, SootMethod> createFlowFunctionsFactory() {
return new FlowFunctions<Stmt, Value, SootMethod>() {
@Override
public FlowFunction<Value> getNormalFlowFunction(Stmt curr, Stmt succ) {
return getNormalFlow(curr);
}
@Override
public FlowFunction<Value> getCallFlowFunction(Stmt callStmt, SootMethod destinationMethod) {
return getCallFlow(callStmt, destinationMethod);
}
@Override
public FlowFunction<Value> getReturnFlowFunction(
Stmt callSite, SootMethod calleeMethod, Stmt exitStmt, Stmt returnSite) {
return getReturnFlow(callSite, calleeMethod, exitStmt);
}
@Override
public FlowFunction<Value> getCallToReturnFlowFunction(Stmt callSite, Stmt returnSite) {
return getCallToReturnFlow(callSite);
}
};
}
|
Normal flow — inside a method body
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25 | /**
* Inside a method body. A tracked object is born at {@code l = new C()}; an assignment between
* locals makes the analysis follow the new name as well.
*/
FlowFunction<Value> getNormalFlow(Stmt curr) {
if (!(curr instanceof JAssignStmt)) {
return Identity.v();
}
JAssignStmt assign = (JAssignStmt) curr;
Value leftOp = assign.getLeftOp();
Value rightOp = assign.getRightOp();
if (rightOp instanceof JNewExpr && rules.containsKey(rightOp.getType())) {
// generate the fact out of the zero fact; the edge function supplies the initial state
return new Gen<>(leftOp, zeroValue());
}
if (rightOp instanceof Local) {
return new AliasFlowFunction(leftOp, rightOp);
}
if (rightOp instanceof JCastExpr) {
return new AliasFlowFunction(leftOp, ((JCastExpr) rightOp).getOp());
}
// anything else overwrites the left-hand side with a value we do not track
return new AliasFlowFunction(leftOp, null);
}
|
new C() for a class we have a rule for is where a tracked object comes into existence:
Gen takes the zero fact and returns both it and the new fact, so the local becomes
tracked from here on. An assignment between locals adds the new name; anything else
overwrites the left-hand side and therefore kills whatever was tracked under that name:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22 | public final class AliasFlowFunction implements FlowFunction<Value> {
private final Value target;
private final Value rightHandSide;
public AliasFlowFunction(Value target, Value rightHandSide) {
this.target = target;
this.rightHandSide = rightHandSide;
}
@Override
public Set<Value> computeTargets(Value source) {
Set<Value> result = new HashSet<>();
if (!source.equals(target)) {
result.add(source);
}
if (rightHandSide != null && source.equals(rightHandSide)) {
result.add(target);
}
return result.isEmpty() ? Collections.emptySet() : result;
}
}
|
Call flow — entering a callee
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24 | /**
* Entering a callee. Arguments are renamed to the callee's parameter locals so that the tracked
* object keeps being followed inside. Calls to the API itself are not entered at all — they are
* summarised on the call-to-return edge instead, which is where the protocol step happens.
*/
FlowFunction<Value> getCallFlow(Stmt callStmt, SootMethod destinationMethod) {
if (rules.containsKey(destinationMethod.getDeclaringClassType())
|| !destinationMethod.hasBody()) {
return KillAll.v();
}
AbstractInvokeExpr invokeExpr = callStmt.asInvokableStmt().getInvokeExpr().get();
List<Immediate> args = invokeExpr.getArgs();
List<Value> parameterLocals = parameterLocalsOf(destinationMethod);
return source -> {
Set<Value> result = new HashSet<>();
for (int i = 0; i < args.size() && i < parameterLocals.size(); i++) {
if (args.get(i).equals(source)) {
result.add(parameterLocals.get(i));
}
}
return result;
};
}
|
Facts are renamed: the fact for an argument at the call site becomes the fact for the
corresponding parameter local in the callee. Nothing else is passed in, so the callee only
sees what it was actually given.
Calls into the API itself are deliberately not entered. FileHandle.open() has a body,
but analysing that body would tell us nothing — the protocol step is the call, and it is
modelled on the call-to-return edge instead. Returning KillAll here is how you say
"summarise this call, do not descend into it".
Return flow — leaving a callee
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26 | /**
* Leaving a callee. Everything local to the callee is killed; only the parameters — mapped back
* onto the caller's arguments — and the returned value survive. Because the edge function on this
* edge is the identity, the state the object reached inside the callee travels back with it.
*/
FlowFunction<Value> getReturnFlow(Stmt callSite, SootMethod calleeMethod, Stmt exitStmt) {
AbstractInvokeExpr invokeExpr = callSite.asInvokableStmt().getInvokeExpr().get();
List<Immediate> args = invokeExpr.getArgs();
List<Value> parameterLocals = parameterLocalsOf(calleeMethod);
Value returnedOp = exitStmt instanceof JReturnStmt ? ((JReturnStmt) exitStmt).getOp() : null;
Value assignedTo =
callSite instanceof JAssignStmt ? ((JAssignStmt) callSite).getLeftOp() : null;
return source -> {
Set<Value> result = new HashSet<>();
for (int i = 0; i < args.size() && i < parameterLocals.size(); i++) {
if (parameterLocals.get(i).equals(source)) {
result.add(args.get(i));
}
}
if (returnedOp != null && assignedTo != null && returnedOp.equals(source)) {
result.add(assignedTo);
}
return result;
};
}
|
The mirror image: parameters are mapped back onto the caller's arguments and the returned
value onto the assignment target; everything else dies with the callee's frame. Because
the edge function on this edge is the identity (Step 10), the state the object reached
inside the callee travels back out with it. This is the step that makes the analysis
interprocedural — remove it and correctUsage would never see its write.
Call-to-return flow — flowing around a call
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20 | /**
* Flowing around a call. For an API call the receiver has to survive, because the edge function
* on this very edge performs the protocol step. For any other call the facts that were handed to
* the callee are killed here — they come back through the return edge, and keeping both copies
* would hide the effect the callee had on them.
*/
FlowFunction<Value> getCallToReturnFlow(Stmt callSite) {
if (ruleFor(callSite) != null || icfg.getCalleesOfCallAt(callSite).isEmpty()) {
return Identity.v();
}
List<Immediate> args = callSite.asInvokableStmt().getInvokeExpr().get().getArgs();
return source -> {
for (Immediate arg : args) {
if (arg.equals(source)) {
return Collections.emptySet();
}
}
return Collections.singleton(source);
};
}
|
This edge exists for the facts that are not affected by the call. For an API call the
receiver has to travel along it, because this is where the protocol step is applied. For
any other call the arguments must not travel along it: they went into the callee and
come back through the return edge, and keeping a second, unchanged copy here would meet
with the updated one and lose the effect of the call.
Step 10 — Edge functions: where the state actually changes
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62 | @Override
protected EdgeFunctions<Stmt, Value, SootMethod, TypestateFact> createEdgeFunctionsFactory() {
return new EdgeFunctions<Stmt, Value, SootMethod, TypestateFact>() {
/** {@code l = new C()} starts the automaton for the new object. */
@Override
public EdgeFunction<TypestateFact> getNormalEdgeFunction(
Stmt curr, Value currNode, Stmt succ, Value succNode) {
if (curr instanceof JAssignStmt) {
JAssignStmt assign = (JAssignStmt) curr;
Typestate automaton = rules.get(assign.getRightOp().getType());
if (assign.getRightOp() instanceof JNewExpr
&& automaton != null
&& currNode.equals(zeroValue())
&& succNode.equals(assign.getLeftOp())) {
return TypestateEdgeFunction.generating(automaton);
}
}
return EdgeIdentity.v();
}
/** {@code receiver.m()} on a tracked class is one step of the protocol. */
@Override
public EdgeFunction<TypestateFact> getCallToReturnEdgeFunction(
Stmt callSite, Value callNode, Stmt returnSite, Value returnSideNode) {
Typestate automaton = ruleFor(callSite);
if (automaton == null) {
return EdgeIdentity.v();
}
AbstractInvokeExpr invokeExpr = callSite.asInvokableStmt().getInvokeExpr().get();
String methodName = invokeExpr.getMethodSignature().getName();
if ("<init>".equals(methodName) || !(invokeExpr instanceof AbstractInstanceInvokeExpr)) {
// the constructor creates the object, it is not a step of the protocol
return EdgeIdentity.v();
}
Local receiver = ((AbstractInstanceInvokeExpr) invokeExpr).getBase();
if (!receiver.equals(callNode) || !receiver.equals(returnSideNode)) {
// some other tracked object flows past this call untouched
return EdgeIdentity.v();
}
return TypestateEdgeFunction.transition(automaton, methodName);
}
/** Call and return edges only rename facts; the state travels along unchanged. */
@Override
public EdgeFunction<TypestateFact> getCallEdgeFunction(
Stmt callStmt, Value srcNode, SootMethod destinationMethod, Value destNode) {
return EdgeIdentity.v();
}
@Override
public EdgeFunction<TypestateFact> getReturnEdgeFunction(
Stmt callSite,
SootMethod calleeMethod,
Stmt exitStmt,
Value exitNode,
Stmt returnSite,
Value retNode) {
return EdgeIdentity.v();
}
};
}
|
Only two of the four ever do anything, and both of them have to be careful about which
fact they were asked about:
l = new C() starts the automaton. The generated fact gets the constant function
"initial state", regardless of the incoming value.
receiver.m() on a tracked class is a protocol step, provided the fact being asked
about really is the receiver. The same call also carries other tracked objects past
it; for those, the edge function must be the identity, or their state would change too.
- The constructor is filtered out:
<init> creates the object, it is not a step of the
protocol.
- Call and return edges are the identity — they only rename facts.
Step 11 — Writing an edge function
An edge function has to support four operations:
computeTarget (apply it), composeWith (do this one, then that one), meetWith
(combine two of them) and equalTo. Heros composes edge functions over and over while it
builds method summaries, so the representation matters:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42 | /** Table slots for the three values that are not automaton states. */
private static final int SLOT_TOP = 0;
private static final int SLOT_BOTTOM = 1;
private static final int SLOT_ERROR = 2;
private static final int SLOT_COUNT = 3;
private final Typestate automaton;
/** {@code table[slotOf(code)]} is the result for an incoming value encoded as {@code code}. */
private final int[] table;
private static int slotOf(int code) {
switch (code) {
case TypestateFact.CODE_TOP:
return SLOT_TOP;
case TypestateFact.CODE_BOTTOM:
return SLOT_BOTTOM;
case TypestateFact.CODE_ERROR:
return SLOT_ERROR;
default:
return SLOT_COUNT + code;
}
}
/** The value a given slot stands for — the inverse of {@link #slotOf}. */
private static int codeOf(int slot) {
switch (slot) {
case SLOT_TOP:
return TypestateFact.CODE_TOP;
case SLOT_BOTTOM:
return TypestateFact.CODE_BOTTOM;
case SLOT_ERROR:
return TypestateFact.CODE_ERROR;
default:
return slot - SLOT_COUNT;
}
}
private int apply(int incomingCode) {
return table[slotOf(incomingCode)];
}
|
Representing a function as "apply this list of events" would grow without bound inside a
loop and the solver would never reach a fixed point. Because the
automaton is finite, every function from value to value can be written down as a table
instead: one slot per state, plus one slot for each special value. Composition and meet
then work slot by slot, equalTo is a table comparison, and only finitely many distinct
edge functions exist — so the solver terminates.
The two functions the analysis actually builds:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26 | /**
* The constant function returning the automaton's initial state, used where a tracked object is
* created. It has to be constant: the fact is generated out of the zero fact, and the zero fact
* carries the bottom value — "reachable", not "in some state".
*/
public static TypestateEdgeFunction generating(Typestate automaton) {
int[] table = new int[SLOT_COUNT + automaton.getStateCount()];
Arrays.fill(table, automaton.getInitialState());
return new TypestateEdgeFunction(automaton, table);
}
/**
* The function performing one protocol step. A state that has no transition for {@code event}
* maps to {@link TypestateFact#ERROR}; the three special values map to themselves.
*/
public static TypestateEdgeFunction transition(Typestate automaton, String event) {
int[] table = new int[SLOT_COUNT + automaton.getStateCount()];
table[SLOT_TOP] = TypestateFact.CODE_TOP;
table[SLOT_BOTTOM] = TypestateFact.CODE_BOTTOM;
// once the protocol is broken it stays broken
table[SLOT_ERROR] = TypestateFact.CODE_ERROR;
for (int state = 0; state < automaton.getStateCount(); state++) {
table[SLOT_COUNT + state] = automaton.trigger(state, event);
}
return new TypestateEdgeFunction(automaton, table);
}
|
And the four operations, all of them entry-by-entry over the table:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60 | @Override
public TypestateFact computeTarget(TypestateFact source) {
return TypestateFact.of(automaton, apply(source.code()));
}
/** Apply {@code this} first, then {@code secondFunction} — entry by entry. */
@Override
public EdgeFunction<TypestateFact> composeWith(EdgeFunction<TypestateFact> secondFunction) {
if (secondFunction instanceof EdgeIdentity) {
return this;
}
if (secondFunction instanceof TypestateEdgeFunction) {
TypestateEdgeFunction second = (TypestateEdgeFunction) secondFunction;
if (second.automaton == automaton) {
int[] composed = new int[table.length];
for (int slot = 0; slot < table.length; slot++) {
composed[slot] = second.apply(table[slot]);
}
return new TypestateEdgeFunction(automaton, composed);
}
}
return secondFunction;
}
@Override
public EdgeFunction<TypestateFact> meetWith(EdgeFunction<TypestateFact> otherFunction) {
if (otherFunction == this || equalTo(otherFunction)) {
return this;
}
if (otherFunction instanceof AllTop) {
return this;
}
int[] met = new int[table.length];
if (otherFunction instanceof EdgeIdentity) {
// the identity maps every value to itself, so meet it slot by slot
for (int slot = 0; slot < table.length; slot++) {
met[slot] = meetCode(codeOf(slot), table[slot]);
}
return new TypestateEdgeFunction(automaton, met);
}
if (otherFunction instanceof TypestateEdgeFunction) {
TypestateEdgeFunction other = (TypestateEdgeFunction) otherFunction;
if (other.automaton == automaton) {
for (int slot = 0; slot < table.length; slot++) {
met[slot] = meetCode(table[slot], other.table[slot]);
}
return new TypestateEdgeFunction(automaton, met);
}
}
return otherFunction;
}
@Override
public boolean equalTo(EdgeFunction<TypestateFact> other) {
if (!(other instanceof TypestateEdgeFunction)) {
return false;
}
TypestateEdgeFunction that = (TypestateEdgeFunction) other;
return automaton == that.automaton && Arrays.equals(table, that.table);
}
|
Composition order
f.composeWith(g) means apply f first, then g — not the mathematical
g ∘ f reading of "compose with". Getting this backwards is the single most common
mistake when writing an IDE problem, and it produces plausible-looking but wrong
states rather than a crash.
Step 12 — Building the ICFG and solving
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31 | /** Loads the target program, builds the ICFG and solves the IDE problem for one entry method. */
static Map<Value, TypestateFact> analyze(String entryMethodName) {
AnalysisInputLocation inputLocation =
new JavaClassPathAnalysisInputLocation(
"src/test/resources/Typestate/binary", SourceType.Application, Collections.emptyList());
JavaView view = new JavaView(Collections.singletonList(inputLocation));
ClassType exampleType = view.getIdentifierFactory().getClassType("Example");
SootClass exampleClass = view.getClass(exampleType).get();
SootMethod entryMethod =
exampleClass.getMethods().stream()
.filter(m -> m.getName().equals(entryMethodName))
.findFirst()
.get();
// the ICFG builds a class-hierarchy call graph from the given entry points
JimpleBasedInterproceduralCFG icfg =
new JimpleBasedInterproceduralCFG(
view, Collections.singletonList(entryMethod.getSignature()), false, false);
Map<ClassType, Typestate> rules = new HashMap<>();
rules.put(view.getIdentifierFactory().getClassType("FileHandle"), fileHandleProtocol());
TypestateProblem problem = new TypestateProblem(icfg, entryMethod, rules);
JimpleIDESolver<Value, TypestateFact, InterproceduralCFG<Stmt, SootMethod>> solver =
new JimpleIDESolver<>(problem);
solver.solve();
List<Stmt> stmts = entryMethod.getBody().getStmts();
return solver.resultsAt(stmts.get(stmts.size() - 1));
}
|
JimpleBasedInterproceduralCFG is SootUp's implementation of the Heros
ICFG interface. Given a View and a list of entry
points it builds a call graph with class hierarchy analysis and stitches
the per-method CFGs together at call sites. The two booleans switch on exceptional
control flow and reflective call resolution; both are off here.
Then it is three lines: construct the problem, hand it to JimpleIDESolver, call
solve(). Results are read per statement with resultsAt, which returns
Map<D, V> — here, a map from local to automaton state — with the zero fact and all
top values already filtered out.
Step 13 — The result
For correctUsage, the analysis computes this (the values shown are the ones holding
before each statement executes):
| $stack1 = new FileHandle {}
specialinvoke $stack1.<FileHandle: void <init>()>() {$stack1=CLOSED}
l0 = $stack1 {$stack1=CLOSED}
virtualinvoke l0.<FileHandle: void open()>() {$stack1=CLOSED, l0=CLOSED}
staticinvoke <Example: void writeGreeting(FileHandle)>(l0) {$stack1=CLOSED, l0=OPEN}
virtualinvoke l0.<FileHandle: void close()>() {$stack1=CLOSED, l0=OPEN}
return {$stack1=CLOSED, l0=CLOSED}
|
l0 is OPEN across the call to writeGreeting and CLOSED again at the end — the
write inside the callee did not break anything. For protocolViolation the same run
ends with l0=ERROR: CLOSED has no write transition, and ERROR is sticky.
Step 14 — Verifying the result
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37 | /** The value the analysis computed for the local with the given name. */
static TypestateFact factFor(Map<Value, TypestateFact> results, String localName) {
return results.entrySet().stream()
.filter(e -> e.getKey().toString().equals(localName))
.map(Map.Entry::getValue)
.findFirst()
.orElseThrow(() -> new AssertionError(localName + " was not tracked; got " + results));
}
@Test
public void correctUsageEndsInAnAcceptingState() {
Map<Value, TypestateFact> results = analyze("correctUsage");
// l0 is the local holding the handle; write() happens inside writeGreeting(), so this only
// works because the analysis follows the call
TypestateFact handle = factFor(results, "l0");
assertEquals("CLOSED", handle.toString());
assertTrue(handle.isAccepting(), "the handle should end in a final state");
assertFalse(results.containsValue(TypestateFact.ERROR), "no violation expected: " + results);
}
@Test
public void violationIsReported() {
Map<Value, TypestateFact> results = analyze("protocolViolation");
// CLOSED has no write transition, so the call folds the value to ERROR and keeps it there
assertEquals(TypestateFact.ERROR, factFor(results, "l0"));
}
@Test
public void aliasesAreTrackedByName() {
// $stack1 and l0 are the same object, but the analysis tracks names, not objects: it never
// learns that a call on l0 also changes the state of $stack1. Removing this limitation needs
// a pointer analysis.
Map<Value, TypestateFact> results = analyze("protocolViolation");
assertEquals("CLOSED", factFor(results, "$stack1").toString());
}
|
CI runs these on every pull request, so a SootUp API change that breaks the analysis fails
the build before it can silently invalidate this page.
The third test is the honest one. $stack1 and l0 are the same object, yet $stack1
stays CLOSED even in protocolViolation: the analysis tracks names, not objects, and
it never learns that a call on l0 also changes the state of $stack1. Fixing that needs
a pointer analysis to supply the alias set of each tracked object — which is
exactly what production typestate tools such as Boomerang do on top of an IDE solver.
Common pitfalls
- The zero fact carries bottom, not top. Generating edge functions must be
constant. See Step 7.
composeWith applies this first. See Step 11.
- Constructors are not protocol steps.
<init> shows up as an ordinary call on
the receiver and will trigger a transition unless you filter it out.
- An edge function is asked for every pair of facts, not just the interesting one.
Always check that the fact you were handed is the receiver before transitioning, and
return
EdgeIdentity.v() otherwise.
- API calls surface on the call-to-return edge. If the ICFG has a body for the API
method, the call edge fires too; return
KillAll there unless you really want to
analyse the API's implementation.
solve() before resultsAt(). resultsAt on an unsolved solver returns an
empty map rather than failing.
- No aliasing. Without a pointer analysis the results hold for the local, not
for the object.
What to try next