Skip to content

fix(core, analyzer): Add hard field limits - #419

Merged
Saloed merged 6 commits into
saloed/5-default-getfrom
saloed/field-limit
Oct 26, 2026
Merged

Saloed merged 6 commits into
saloed/5-default-getfrom
saloed/field-limit

Conversation

@Saloed

@Saloed Saloed commented Oct 22, 2026

Copy link
Copy Markdown
Contributor

No description provided.

@Saloed
Saloed added this pull request to stack #343 October 22, 2026 15:05
@Saloed
Saloed force-pushed the saloed/field-limit branch from b0af90c to f6e7de3 Compare October 23, 2026 12:48
@Saloed Saloed changed the title fix(core, analyzer): Add hard field limits fix(cli, core, analyzer): Add hard field limits Oct 24, 2026
@Saloed Saloed changed the title fix(cli, core, analyzer): Add hard field limits fix(core, analyzer): Add hard field limits Oct 24, 2026
@Saloed

Saloed commented Oct 24, 2026

Copy link
Copy Markdown
Contributor Author

Saloed and others added 6 commits October 25, 2026 13:52
…limit

Replaces the separate field limiter with an adaptation of the existing
iterative-deepening (ID) fact-depth ladder in NormalMethodAnalyzer.

Depth measure. The ID gate (edgeExceedLimit) now measures field depth via
FactAp.exceedsFieldLimit instead of FactAp.depth. Field depth is the maximum,
over every access path of a fact, of the number of field-like accessors on
that path: FieldAccessor, ElementAccessor ([*]) and AnyAccessor ([any]) each
count 1; taint marks, the final accessor, class statics, value, type-info group
and type-info accessors are not counted. The old measure, Tree
AccessNode.maxDepth, counted every accessor and charged +10,000 for each node
owning an [any] child, so a single [any] pushed a fact out of reach of the
ladder for 10,000 rounds. Tree AccessNode precomputes maxFieldDepth in its init
block; AccessPath walks its chain with early exit; Automata runs a monotone
longest-path relaxation that stops once the limit is exceeded; Cactus treats a
cycle through a counted accessor as unbounded.

Ladder. INITIAL_ALLOWED_FACT_DEPTH is now 1 and the comparison is strict: an
F2F initial edge or F2F summary is delayed when its initial fact or its final
fact has field depth above the current limit (the previous +2 slack for the
final fact is gone). Which edges the ladder gates is unchanged (initial F2F
edges and F2F summaries); zero-fact edges are not gated.

Cap. With --field-limit N the analyzer's limit is min(ladder limit, N), also at
construction and on resetApManager. An edge over the limit while the analyzer
is already at the cap is delayed forever: it is neither stored in the delayed
lists nor does it register the analyzer as delayed. Registering it would make
TaintAnalysisUnitRunnerManager resume the unit at every quiescence, the unit
would replay the same edges without progress, and the analysis would livelock
until the IFDS timeout. Each registration therefore implies the analyzer is
below the cap, and each resume strictly raises its limit, so every analyzer
registers at most N times and analysisCompletion fires once nothing is left
to admit. Without --field-limit the ladder is unbounded as before. Dropped
edges are counted in a LongAdder in the runner manager, which logs
`Field limit N: dropped K edges over the limit` at the end of every analysis
phase.

The option is plumbed from the Kotlin CLI (--field-limit) through
CommonAnalysisOptions, TaintAnalyzerOptions, TaintAnalyzer,
TaintAnalysisUnitRunnerManager, TaintAnalysisUnitRunner and
MethodAnalyzerStorage to NormalMethodAnalyzer; the Go CLI gets
`opentaint scan --field-limit N` (0 = unlimited).

Trace resolution. MethodTraceResolver's TraceBuilder.addPredecessor drops
predecessor entries whose trace edges exceed the field limit, so the backward
search cannot deepen preconditions without bound through [any]-containing
facts. This gate also covers source (zero-fact) trace edges, which the forward
ladder does not gate.

Tests: FieldLimitTest (field depth of Tree facts) and FieldLimitAnalysisTest
(a depth-3 flow through a callee summary and a callee initial fact is found
unlimited and with limit 3 and dropped with limit 2; a depth-1 flow is found
with limit 1 and dropped with limit 0).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The ladder only gated initial fact-to-fact edges and fact-to-fact
summaries, so facts that originate at sources (zero-to-fact) or grow
inside a method were never checked: on conductor with --field-limit=4
the full scan still ran into the memory guard at ~9M events.
addSequentialEdge now delays (below the cap) or drops (at the cap) any
edge whose initial or final facts exceed the current limit, the delayed
edges are replayed on every limit raise, and summaries of every edge
kind are gated.

Automata and Cactus field depth counted any cycle through a field-like
accessor as unbounded, so with every edge gated those facts were never
admitted even without a field limit (9 Automata analysis tests failed).
A cycle now counts once, like the cycle-free Cactus depth.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ing the whole edge

A Tree fact is a set of access paths, and an edge was delayed (below the
cap) or dropped (at the cap) as soon as any one of its paths went over
the current field-depth limit. A fact that merged a shallow path with a
deep one therefore lost the shallow path as well. This happens when a
callee's summary delivers ret.{$, value.$} as a single tree and the
caller then stores it into a field.

Every analyzer gate (initial F2F edges, sequential edges of all kinds,
summaries) now goes through admitEdgeWithinLimit:
- an initial fact over the limit delays or drops the whole edge, as before;
- final fact max field depth <= limit: the edge proceeds unchanged
  (checked first, so this path allocates nothing);
- final fact min field depth > limit: the whole edge is delayed or dropped;
- otherwise the final fact is split. The edge with the within-limit part
  proceeds, and an edge with the rest part goes to the gate's delayed
  list (or is dropped and counted at the cap). A delayed rest edge is
  replayed through the same gate, so it can split again at the next rung.
ZeroToZero edges are never gated.

FinalFactAp.splitFieldLimit(limit) returns FieldLimitSplit(within, rest).
Together within and rest represent exactly the paths of the original
fact. Every path in within has field depth <= limit, every path in rest
has field depth > limit, and an empty part is null. A path ends at a
final or abstract node.

Tree: AccessNode.fieldLimitWithin keeps every node reachable with counted
depth <= limit, along with its final/abstract flags and deep-accessor
exclusion. It drops child subtrees whose field-like accessor would push
the depth over the limit. AccessNode.fieldLimitRest keeps only the cut
branches (whole), rooted at the same base. The prefix nodes become plain
structure with no final/abstract flag and no deep exclusion, so a node
that is terminal at depth == limit stays in within while its deeper
children move to rest. Both parts are built with the existing
transformAccessors/manager.create helpers and keep the base and
exclusions. AccessNode.allFieldPathsExceed computes min > limit on
demand, with early exit, only when maxFieldDepth > limit. No new
per-node field is stored.

Automata and Cactus use the interface default, which is all-or-nothing:
(this, null) within the limit, (null, this) otherwise.

Tests: FieldLimitTest covers the split (branch partition, fast paths,
terminal node at the limit with deeper children, [any] counting 1,
marks/final not counting, abstract leaves with deep exclusions, base
exclusions). FieldLimitAnalysisTest adds mixedDepthFlow: a callee
returns a Leaf tainted both at its base and at .value. The caller stores
it into holder.leaf, and two sinks read holder.leaf and holder.leaf.value.
With --field-limit 1 only the shallow sink is reported (on the previous
commit neither was), and without a limit both are reported.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…th without a field limit

Without --field-limit the backward trace gate was inactive, and the
start-to-final search concretises forward [any] facts into every
ordering of distinct fields: a stored `x.[any]` fact contains all of
them, so the contains-entry check never rejects one. On conductor the
spring dispatcher loop inserts one summary field per pass and the
search never closes.

The gate now uses the field limit when it is set, and otherwise the
highest fact-depth ladder level reached in the phase plus one. The
slack is needed because a single forward [any] counts as one field but
a trace may have to spell out two concrete fields under it (the starred
clean and any-field-source cleaner tests fail with no slack). The
reached level is logged as `Fact depth ladder reached N` at the end of
each phase.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…t at a fixed depth

The trace gate dropped every predecessor whose facts exceeded a fixed
depth (the field limit, or the reached ladder level plus one), which
needed a hand-tuned slack because a single forward [any] fact may have
to be spelled out as several concrete fields.

The trace builder now runs its own iterative deepening. It starts at
the last reached forward ladder level (capped by the field limit) and
delays, rather than drops, a predecessor whose facts exceed the current
depth. When the queue drains, the search stops and discards the delayed
predecessors if a start entry was reached; otherwise it raises the
depth by one and re-admits the delayed predecessors that now fit. The
full-trace rebuild for a known start waits for that particular start,
so it cannot stop at a shallower different one. Shortest witnesses are
found first, and a search that reaches a start never expands deeper.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… simplify field depth

1. Rename the option to "iteration limit": the analyzer CLI flag is now
   --iteration-limit and the Go CLI forwards `opentaint scan --iteration-limit`
   as --iteration-limit=N (only when > 0). The plumbing is renamed through
   CommonAnalysisOptions, TaintAnalyzerOptions, TaintAnalyzer, the runner
   manager, the runner, MethodAnalyzerStorage and NormalMethodAnalyzer, as are
   the analysis test, its sample and rule ids. The option caps the
   iterative-deepening fact depth; the fact-level field-depth API keeps its names.
2. AccessGraph measures field depth as the number of field-depth accessors in
   the graph (edge keys), replacing the longest-path traversal.
3. AccessPath and the Cactus AccessPathWithCycles precompute the field count per
   node next to size, making exceedsFieldLimit an O(1) comparison.
4. AccessTree nodes precompute minFieldDepth (shallowest terminal path) next to
   maxFieldDepth, both as Short so the node layout does not grow. splitFieldLimit,
   fieldLimitWithin and fieldLimitRest prune whole subtrees with the min/max
   bounds; allFieldPathsExceed is gone.
5. FinalFactAp.splitFieldLimit has no default; the access graph and cactus
   implement the all-or-nothing split explicitly until a precise split exists.
6. AnalysisRunner no longer exposes handleEdgeDroppedOverFieldLimit or
   reachedFactDepthLimit. Over-limit drops are not counted, and the trace
   resolver starts its backward deepening from the method analyzer's own
   capped fact depth limit.
7. The runner manager and runner only pass the iteration limit through; the
   drop counter, the limit report and the reached-ladder tracking are removed,
   keeping the runner/manager API surface unchanged apart from the parameter.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Saloed

Saloed commented Oct 26, 2026

Copy link
Copy Markdown
Contributor Author

https://github.com/seqra/opentaint-test/actions/runs/40292098778

@Saloed
Saloed requested a review from misonijnik October 26, 2026 09:58
@Saloed
Saloed merged commit 350c584 into main Oct 26, 2026
16 of 17 checks passed
@Saloed
Saloed deleted the saloed/field-limit branch October 26, 2026 10:01
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.

2 participants