Skip to content

Precision & recall improvements for the Andersen points-to analysis#2

Open
drmckay wants to merge 28 commits into
rootaux:mainfrom
drmckay:eff/pointsto-improvements
Open

Precision & recall improvements for the Andersen points-to analysis#2
drmckay wants to merge 28 commits into
rootaux:mainfrom
drmckay:eff/pointsto-improvements

Conversation

@drmckay

@drmckay drmckay commented Jul 6, 2026

Copy link
Copy Markdown

Builds on #1 (solver performance). That branch is still open and this one is stacked on it, so the diff here currently also lists those 8 performance commits — they drop out automatically once #1 merges. The new work is the 20 precision/recall commits summarized below.

What this changes

The Andersen points-to analysis previously dropped or over-approximated many common Java constructs, so CPG call-graph and dataflow queries saw missing or imprecise targets. This branch closes those gaps, each as a self-contained, tested commit.

Allocation & expression modelling

  • Nested / unassigned allocations (foo(new A()), factory/builder returns) flow into points-to sets instead of being dropped.
  • Cast expressions (T) x preserve points-to (identity) instead of erasing it.
  • Field reads use the same concrete-type slot as writes, so a stored value is read back even through a polymorphic or unknown-typed base.
  • Ternary cond ? a : b points to the union of both branches.

Collections, arrays & functional interfaces

  • Synthetic element slot for collections (add/get/poll/..., index-insensitive) and arrays (a[i]).
  • Lambdas and method references modelled as allocations of a functional object; functional dispatch (run/apply/get/accept/...) resolves to the synthetic method.

Overload / generics resolution

  • Same-name overloads resolve to the full candidate set (exact signature match when available) instead of an arbitrary pick.

Static fields & singletons

  • Static-field writes (Type.field = ..., whether the base is a TypeRef or a type-named identifier) propagate, so singletons flow.

Dependency injection

  • Vert.x collector guarded to io.vertx.* calls, so a same-named register(...) in other frameworks no longer produces false bindings.
  • Collection injection (@Inject List<Handler>) seeds every bean of the element type (from the member's generic signature).
  • Spring 4.3+ implicit single-constructor injection (a managed class's sole constructor is autowired without @Autowired).
  • @Qualifier("name") narrows a multi-impl injection to the named bean, with a recall-preserving fallback to all impls when the name can't be resolved.
  • Spring FactoryBean<T> binds the bean type to the concrete type getObject() returns.

Reflection

  • Class.forName(name).newInstance() and ...getDeclaredConstructor().newInstance() allocate the named type (from the string literal), resolved through the constructor chain.
  • ServiceLoader.load(Foo.class) modelled as a collection whose elements are the service interface's implementations (CHA, since META-INF/services is not in the CPG), with iterator()/stream() pass-through and next() as an element load.

Exception flow

  • Frontend (javasrc2cpg): the catch-clause parameter catch (T e) becomes a proper local in the catch scope — previously the parameter was dropped entirely, leaving e an unresolved identifier. Useful to any analysis needing the caught-exception binding, not just PTA.
  • Analysis: thrown objects flow to the catch parameter, type-soundly (only a thrown object whose static type is the catch type or a subtype of it, via the transitive supertype check).

Testing

19 dedicated recall/precision cases in PointerAnalysisFactoryTests, plus DiCollectorTests, a ControlStructureTests catch-parameter test, and the x2cpg solver unit tests (AndersenSolverCycleTests, AndersenSolverFieldTests). Each commit was built and its tests run in a JDK21 container; the surrounding call-graph and dataflow suites stay green.

Deliberately out of scope

Pruning over-approximate CHA edges — removing PTA-refuted targets from CALL edges — was considered and rejected. It is unsound whenever points-to is only partially modelled (framework-callback parameters invoked by Spring/Vert.x, cond ? new A() : libraryReturn() branches): the points-to set is non-empty but incomplete, and removing a CHA edge then drops a real target from reachableByFlows — a lost finding in a security CPG. The resolver-aware path (PointerAnalysisCallResolver.getResolvedCalledMethods) already delivers the precision; raw CALL edges stay CHA-sound on purpose.

drmckay added 28 commits July 6, 2026 12:53
The Andersen worklist re-unioned the whole points-to set of a variable into
every subset successor on every fire. PointsToSet already shipped the delta
primitives (diffBits/absorb) but they were unused. Track, per variable, the
allocation-site indices already propagated out of it and push only the delta
each fire.

addSubsetEdge (full seed of a newly created edge) and the field / virtual-call
discharges (idempotent / seen-guarded, read the full current set) are unchanged,
so the least fixpoint is identical — this is a speedup, not a semantics change.

Verified: x2cpg compiles; javasrc2cpg CallGraphTests 7/7 pass.
The solver keyed pt / subsetOut / propagated / loads / stores / vcalls / worklist
by the packed "$ctx|$var" string, so every hot-loop map access hashed and compared
a full string. Intern each packed variable to a dense int id once (when it is first
built) and key all hot-path maps by that id; the fixpoint loop no longer touches a
string. solve() re-keys its result back to the packed strings for consumers, so
PointerAnalysis is unchanged.

This also sets up array-backed cycle elimination (union-find over int ids) as the
next step.

Verified: x2cpg compiles; javasrc2cpg CallGraphTests + CallTests +
ConstructorInvocationTests 27/27 pass.
Recursive and mutually-recursive copies create cycles in the subset (copy) graph;
every member of a strongly connected component shares one final points-to set, but
without collapsing them the worklist churns the same allocation sites around the
cycle O(cycle length) times.

Add union-find over the interned variable ids. While propagating along v -> d, when
d's set is left unchanged and equal to v's and d can reach v in the subset graph, v
and d are in the same SCC and are merged (deferred until after the propagation loop;
the merged node's delta bookkeeping is reset and it is re-enqueued). All hot-path map
accesses resolve through find().

The call graph is unaffected: refineCallEdges reads resolvedCallTargets, keyed by call
node id and derived from receiver alloc types, independent of how points-to variables
are grouped.

Verified: x2cpg compiles; new AndersenSolverCycleTests (copy-cycle collapse, acyclic
control, two-alloc cycle) pass; javasrc2cpg CallGraphTests, CallTests,
ConstructorInvocationTests and dataflow ObjectTests pass. The dataflow MethodReturnTests
'external method with semantic' case fails identically on pristine upstream aa3c8e0, so
it is a pre-existing failure unrelated to this change.
When a base variable's points-to set grew, every deferred load/store on it was
re-discharged against the whole set, each call re-iterating all base allocations and
allocating a fresh Set[String] of their types — pure waste after the first fire, since
addSubsetEdge is idempotent.

Track, per base, the allocation indices already wired into field slots; on each fire
wire only the newly-arrived allocations' types into every deferred load/store slot. A
newly added load/store constraint still gets the full current base set once via
dischargeLoad/dischargeStore in interpret. A cycle collapse resets the per-base
bookkeeping (like the propagation delta) and re-enqueues the representative.

Verified: x2cpg compiles; new AndersenSolverFieldTests (store->load flow, two-type base
growth, distinct field names) and AndersenSolverCycleTests pass; javasrc2cpg
CallGraphTests, CallTests, ConstructorInvocationTests and dataflow ObjectTests pass.
add / unionInPlace / absorb each computed mutable.BitSet.size (a full popcount over all
words) before and after the mutation just to report whether the set changed, making a
single-element add O(setSize/64) and every union two full popcounts.

add now uses mutable.BitSet.add, which is O(1) and already reports whether the bit was
newly set. unionInPlace / absorb compute the difference (delta &~ bits), which
short-circuits on the first differing word, and union only when it is non-empty;
unionInPlace delegates to absorb. Change-report semantics are identical.

Verified: AndersenSolverCycleTests, AndersenSolverFieldTests and javasrc2cpg
CallGraphTests, CallTests, ConstructorInvocationTests, dataflow ObjectTests pass.
enqueue never checked whether a variable was already queued, so a hot variable could sit
in the FIFO many times and be dequeued and fully re-processed (all lookups + discharges)
with no new information.

Track the representatives currently on the worklist in a set; enqueue is a no-op when the
variable is already queued. The flag is cleared on dequeue *before* processing, so a
re-enqueue triggered during processing still takes effect, and a cycle collapse clears the
merged node's flag. The fixpoint result is unchanged (delta propagation already ensures a
dequeue processes the variable's current set), only redundant fires are removed.

Verified: AndersenSolverCycleTests, AndersenSolverFieldTests and javasrc2cpg
CallGraphTests, CallTests, ConstructorInvocationTests, dataflow ObjectTests pass.
dischargeVirtualCall re-materialised the whole receiver points-to set
(rset.iterator.toArray) on every fire and then filtered each element through the per-call
`seen` set — an O(|receiver set|) scan plus array allocation even when `seen` rejected
every element.

`seen` is a mutable.BitSet now, so the discharge computes the delta directly
(rset.diffBits(seen)) and returns immediately when no new receiver allocation has arrived;
only the new allocation sites are resolved and instantiated. Semantics are unchanged — the
same allocations are processed exactly once per call site.

Verified: AndersenSolverCycleTests, AndersenSolverFieldTests and javasrc2cpg
CallGraphTests, CallTests, ConstructorInvocationTests, dataflow ObjectTests pass.
pt.getOrElse(x, PointsToSet.empty) allocated a fresh throwaway PointsToSet (and its
mutable.BitSet) on every read-miss, immediately discarded.

Add a shared read-only PointsToSet.EMPTY sentinel and use it for the eight getOrElse
read-miss lookups, all of which only read the result (nonEmpty / diffBits / typesOf) and
short-circuit when it is empty, so the sentinel is never mutated. The five getOrElseUpdate
sites, which insert and mutate, keep allocating a fresh instance.

Verified: AndersenSolverCycleTests, AndersenSolverFieldTests and javasrc2cpg
CallGraphTests, CallTests, ConstructorInvocationTests, dataflow ObjectTests pass.
`new Foo(...)` lowers to a Block whose last child is the tmp identifier holding the freshly
allocated object; its inner `$tmp = <operator>.alloc` assignment is already turned into an
Alloc by handleAssignment. But exprVar mapped the Block to None, so an allocation in
argument / return / nested position was dropped and the object type never entered any
points-to set — defeating factory/builder dispatch resolution.

exprVar now maps a Block to its last expression child (the tmp identifier), which already
points to the allocation, so no new Alloc emission is needed.

A `return new Impl()` factory dispatch now resolves precisely (new test). An allocation in
argument position reaches argVars but is not yet bound to the callee parameter, due to a
separate argument->parameter index mismatch (pending test, fixed next).

Verified: new PointerAnalysisFactoryTests (return position) passes; javasrc2cpg
CallGraphTests, ConstructorInvocationTests, dataflow ObjectTests and the x2cpg solver unit
tests pass.
argVars was built with call.argument.flatMap(exprVar), which drops primitive arguments and
(on static calls) has no receiver slot, so the resulting Vector no longer aligned with
parameter indices — the solver's argVars.lift(paramIndex) bound arguments to the wrong
parameter or missed them entirely. Only the CPG AST argument edges were tested, so this
points-to mis-binding went unnoticed.

Key argVars by Joern argumentIndex (Map[Int, String]) — receiver/this = 0, explicit
arguments from 1, matching MethodParameterIn.index — and look them up with .get(index) in
both the static and virtual call discharge. Reference arguments (including allocations in
argument position from the previous commit) now reach the correct callee parameter.

Verified: PointerAnalysisFactoryTests argument-position case now resolves (pending removed);
javasrc2cpg CallGraphTests, CallTests, ConstructorInvocationTests, dataflow ObjectTests and
the x2cpg solver unit tests pass.
A Java cast lowers to `<operator>.cast(TypeRef, operand)`, an operator call that exprVar
rejected and handleAssignment did not match, so `Foo f = (Foo) bar` and casts in
argument/return position dropped the object — severing the points-to chain on exactly the
values that most need it (collection.get(), Optional.get(), widened factory results).

A cast is identity for points-to, so exprVar now maps `<operator>.cast` to its operand (the
last argument; the target type is a TypeRef in argument position 1), and a cast RHS in an
assignment emits a Copy from the operand.

Verified: new cast test (dispatch through `(Greeter) o` resolves precisely to the concrete
type) passes; javasrc2cpg CallGraphTests, CallTests, ConstructorInvocationTests,
SpecialOperatorTests, dataflow ObjectTests and the x2cpg solver unit tests pass.
Array access `a[i]` lowers to `<operator>.indexAccess(base, index)`, an operator call, so an
array store (`a[i] = x`, index-access LHS) and an array load (`x = a[i]`, index-access RHS or
value position) were both dropped — array elements carried no points-to, and a reference
stored into an array never resolved a later dispatch on it.

Model an array index-insensitively as an object with one synthetic `[]` element slot, on the
existing Load/Store field machinery: `a[i] = x` stores into the base's `[]` slot, `x = a[i]`
loads it, and `a[i]` in value position reads the slot. Array allocation (`new T[n]`) already
produces a typed `<operator>.alloc`, so the slot resolves per concrete array type.

Collections (list.add/get) are handled separately next.

Verified: new array-element dispatch test resolves precisely; javasrc2cpg CallGraphTests,
ConstructorInvocationTests, ArrayTests, dataflow ArrayTests, dataflow ObjectTests and the
x2cpg solver unit tests pass.
list.add(x) / list.get() are virtual calls into JDK types that have no method body in the
CPG, so they resolved to nothing: a reference put into a collection never flowed to a later
read, and an interface stored in a List never resolved a dispatch on the element.

Model a collection element-insensitively as an object with one synthetic <collElem> slot,
recognised conservatively by receiver type (java.util.* or a core collection interface name,
since the JDK type hierarchy is not in the CPG) and a fixed set of accessor methods:
add/offer/push/put/set/... store the last reference argument into the slot,
get/poll/peek/remove/... load it. Handled before virtual-call emission, on the existing
Load/Store field machinery.

Verified: new collection-dispatch test resolves precisely; javasrc2cpg CallGraphTests,
CallTests, ConstructorInvocationTests, ArrayTests, dataflow ObjectTests and the x2cpg solver
unit tests pass.
A lambda or method reference lowers to a MethodRef node whose methodFullName is the
synthetic lambda method (or referenced method); exprVar had no case for it, so a functional
variable (Runnable/Supplier/Function) carried no points-to and the later r.run() / s.get()
dispatch resolved to nothing.

Model a MethodRef as an allocation of a functional object whose type is the target method's
full name, and resolve any functional dispatch on it straight to that method in lookupMethod
(the receiver "type" is itself a method full name). Also handle a block- or method-ref-valued
assignment RHS.

Also exclude java.util.function.* (functional interfaces) from the collection model, so their
accessors (Supplier.get, ...) stay virtual dispatches that resolve to the lambda body instead
of being mistaken for collection loads.

Verified: new lambda + method-reference dispatch tests resolve to the lambda body / referenced
method; javasrc2cpg CallGraphTests, CallTests, ConstructorInvocationTests, dataflow ObjectTests
and the x2cpg solver unit tests pass. The pre-existing LambdaTests "captured variables" AST
failure is unrelated (fails identically on upstream aa3c8e0).
Value-position field reads (a field access as a call argument or return operand) used a
declared-type slot F:<declaredType>:f via fieldSlotFromAccess, while writes and
assignment-loads use the concrete-type slot F:<concreteType>:f resolved from pt(base). For a
polymorphic or unknown-typed base these differ, so a value stored into the concrete slot was
not read back — silently losing the flow (e.g. sink(obj.f), return obj.f).

exprVar now emits a Load through the base pointer variable for a value-position field read,
resolving to the same concrete slot the write used. fieldSlotFromAccess is removed.

This exposed that a direct `new T(...)` in value position (e.g. a store RHS, which lowers to
an <init>/alloc call, not a block) was dropped by exprVar, so the store never reached the
concrete field slot. exprVar now models a direct allocation call into a synthetic variable.

Verified: new polymorphic-base field-read test resolves precisely; javasrc2cpg CallGraphTests,
CallTests, ConstructorInvocationTests, dataflow ObjectTests and the x2cpg solver unit tests pass.
lookupMethod tried the exact (name, signature) then fell back to the first name-matching
method in an unordered map, so an overloaded call whose call-site signature did not match
textually (generics erasure, bridge methods, ANY-typed args) was routed to a
nondeterministic, possibly wrong overload — a false-target CALL edge (taint into the wrong
body) or a missed target.

lookupMethods now returns a set: an exact (name, signature) match gives a single precise
target; otherwise it returns ALL same-name overloads on the most specific type that has one.
Deterministic and recall-preserving (over-approximate) rather than arbitrary. The virtual
call discharge instantiates and binds each resolved target.

Verified: new overload test routes to the exact overload (not the decoy); javasrc2cpg
CallGraphTests, CallTests, ConstructorInvocationTests, dataflow ObjectTests and the x2cpg
solver unit tests pass.
A static field access `Type.field` has a TypeRef base (or, inconsistently, a type-named
Identifier) rather than an object, so resolving its slot through the base's points-to gave
nothing: the write in <clinit> (`INSTANCE = new Foo()`) never discharged and a read
(`return INSTANCE`, `Holder.INSTANCE`) saw an empty slot — classic getInstance() singletons
carried no object.

Recognise a static field access (base is a TypeRef, or an Identifier whose name is a known
type) and scope its slot directly to the declaring type (F:Type:field), copying into it on
write and out of it on read, instead of the pt(base)-resolved instance-field machinery.

Verified: new singleton dispatch test resolves precisely; javasrc2cpg CallGraphTests,
CallTests, ConstructorInvocationTests, dataflow ObjectTests, dataflow StaticMemberTests and
the x2cpg solver unit tests pass.
collectServiceProxyRegistrations and collectVerticleDeployments hooked every register /
registerService / deployVerticle call by name, with no type guard (unlike the Guice and
Dropwizard collectors, which gate on ancestorTypeFullNames). Any unrelated same-named call
in user code or another framework — e.g. a 3-arg register(Iface.class, impl) — produced a
spurious interface->impl binding, a precision defect that misroutes dispatch.

Guard each hook with isVertxCall: a genuine Vert.x call resolves to a method under the
io.vertx package. Unrelated calls no longer bind; real ServiceBinder.register /
ProxyHelper.registerService / Vertx.deployVerticle still do.

Verified: new DiCollectorTests (unrelated register not bound; genuine io.vertx register
bound); javasrc2cpg CallGraphTests, PointerAnalysisFactoryTests and the x2cpg solver unit
tests pass.
seedInjectedFields looked up implsFor on the raw field type of an injected member. For a
collection field (@Inject List<Handler>) that raw type is java.util.List, which has no DI
bindings, so Spring's "inject all beans of a type" was invisible — the generic element type
was never extracted.

For a collection-typed injected field, extract the generic element type from the member's
JVM generic signature (Ljava.util.List<LHandler;>; -> Handler), model the field as a synthetic
collection, and seed its element slot (the same <collElem> slot collection reads use) with an
alloc of every impl of the element type.

Verified: new collection-injection test resolves a dispatch on an element read from an injected
List to every bean impl; javasrc2cpg PointerAnalysisFactoryTests, DiCollectorTests,
CallGraphTests and the x2cpg solver unit tests pass.
seedInjectedParameters only injected constructor parameters when the constructor, method, or
parameter carried an inject annotation. Spring 4.3+ injects a bean's single constructor
implicitly, with no @Autowired, so those parameters were missed.

When a DI-managed class (a registered class or a bound impl) has exactly one constructor,
autowire its parameters from the DI bindings like an annotated injection point.

Verified: new test resolves a dispatch on an unannotated single-constructor parameter to every
bean impl; javasrc2cpg PointerAnalysisFactoryTests, DiCollectorTests, CallGraphTests,
ConstructorInvocationTests and the x2cpg solver unit tests pass.
seedInjectedFields seeded an injected field with an alloc of every impl of the field type, so
an interface with several impls produced several dispatch targets even at a @qualifier("name")
pinned site that names exactly one bean — a precision defect (spurious targets).

Read the @qualifier value and keep only the impl whose default bean name (simple name
decapitalised) matches it. If no impl name matches — e.g. an explicit @component("name") we do
not resolve — fall back to all impls, so precision never costs recall.

Verified: new test injects only the qualified bean (dispatch resolves to it, not the other
impl); javasrc2cpg PointerAnalysisFactoryTests, DiCollectorTests, CallGraphTests and the x2cpg
solver unit tests pass.
A Spring FactoryBean<T> supplies its bean from getObject() rather than by being the bean
itself, so an @Autowired T resolved to nothing — the SpringCollector never looked at
getObject().

Collect a binding from the getObject() return type to the concrete type it allocates and
returns (Greeter -> RealGreeter), the same shape as an @bean factory method. The existing
field/parameter seeding then injects it.

Verified: new FactoryBean test resolves an @Autowired dispatch to the concrete type getObject()
returns (not another impl); javasrc2cpg PointerAnalysisFactoryTests, DiCollectorTests,
CallGraphTests, CallTests, ConstructorInvocationTests and the x2cpg solver unit tests pass.
Class.forName("Foo").newInstance() creates a Foo, but the JDK newInstance returns Object with
no allocation, so reflectively created objects were invisible and any later dispatch on them
went unresolved.

When a newInstance call's receiver is Class.forName with a string-literal argument, model the
result as an alloc of the named type. Handled before dispatch, since the virtual call into
java.lang.Class resolves to nothing anyway.

Verified: new reflection test allocates the named type and resolves a dispatch through the cast
of the result; javasrc2cpg PointerAnalysisFactoryTests, DiCollectorTests, CallGraphTests,
CallTests and the x2cpg solver unit tests pass.
reflectiveAllocType only recognised Class.forName(name).newInstance(). The common
Class.forName(name).getDeclaredConstructor(...).newInstance(...) chain (and getConstructor)
was missed, so those reflectively created objects stayed invisible.

Walk the receiver chain through getDeclaredConstructor / getConstructor back to the forName
string literal, so the newInstance result is an alloc of the named type regardless of how the
constructor was obtained.

Verified: new getDeclaredConstructor().newInstance() test resolves a dispatch through the cast
of the result; javasrc2cpg PointerAnalysisFactoryTests, DiCollectorTests, CallGraphTests pass.
ServiceLoader.load(Foo.class) is iterated to obtain Foo instances, but the loader carried no
points-to (its contents come from META-INF/services, which is not in the CPG), so a dispatch
on an iterated element resolved to nothing.

Model the load result as a synthetic collection whose element slot holds every impl of the
service interface (from the CPG's inheritance edges, since META-INF is unavailable). Add
iterator()/stream()/... as collection pass-throughs (the view carries the same elements) and
next() as an element load, so `for (Foo f : load(Foo.class))` reads the impls.

Verified: new ServiceLoader test resolves a dispatch on an iterated element to every impl of
the service; javasrc2cpg PointerAnalysisFactoryTests, DiCollectorTests, CallGraphTests,
CallTests, ConstructorInvocationTests and the x2cpg solver unit tests pass.
astForCatchClause lowered only the catch body, dropping the exception parameter entirely:
`catch (T e)` left `e` as an unresolved identifier with no backing local, so nothing downstream
could bind the caught exception to a variable.

Introduce a local for the parameter in the catch scope (same shape as the for-each item local)
and register it, so uses of the caught variable resolve to it via REF edges.

Verified: new test asserts the catch parameter becomes a typed local referenced by its uses;
javasrc2cpg ControlStructureTests, dataflow TryTests, CapturingTests, TypeInferenceTests pass.
collectFromMethod never visited throw operands or catch clauses, so a thrown object never reached
the catch parameter and any dispatch on the caught variable resolved to nothing.

For each `<operator>.throw`, take the thrown object's pointer variable and static type; for each
catch clause, copy every thrown object whose type is the catch type or a subtype of it into the
catch parameter local. Over-approximates the throw/catch pairing (any throw in the method reaches
any catch) but stays type-sound via the transitive supertype check.

Verified: new test flows a thrown SubException to catch (MyException e) and resolves e.handle() to
SubException.handle only (not a sibling exception's); x2cpg solver unit tests, javasrc2cpg
PointerAnalysisFactoryTests, DiCollectorTests, CallGraphTests, ControlStructureTests, TryTests pass.
exprVar and handleAssignment had no case for `<operator>.conditional`, so `x = cond ? a : b`
dropped the ternary entirely — x carried no points-to and any dispatch on it went unresolved.

Model the conditional's result as the union of its two branches (then/else arguments), and copy
that union into the assignment target. A cast/allocation/nested branch flows through since each
branch is resolved via exprVar.

Verified: new test points the ternary result to both branch allocations and resolves a dispatch
on it to each (not a third impl); x2cpg solver unit tests, javasrc2cpg PointerAnalysisFactoryTests,
DiCollectorTests, CallGraphTests, CallTests pass.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant