Skip to content

fix: int64 overflow in linear/log iterator reporting level (near INT64_MAX) - #149

Open
filipecosta90 wants to merge 13 commits into
mainfrom
fix/iterator-reporting-level-overflow
Open

filipecosta90 wants to merge 13 commits into
mainfrom
fix/iterator-reporting-level-overflow

Conversation

@filipecosta90

Copy link
Copy Markdown
Contributor

Stacked on #148 (the iterators call move_next, whose value-range overflow #148 fixes). Base this PR on #148; retarget to main once #148 merges.

Bug (found by adversarial review during the ClusterFuzzLite effort)

For a histogram with highest_trackable_value near INT64_MAX, the linear and log iterators overflow int64 when advancing the reporting level:

  • iter_linear_next (:1162): next_value_reporting_level += value_units_per_bucket
  • log_iter_next (:1219): next_value_reporting_level *= (int64_t) log_base

Both are in-contract (public iterators) and reachable via any full linear/log iteration of such a histogram; the wrapped negative level then triggers a negative left-shift in lowest_equivalent_value.

Fix

Saturate the reporting level at INT64_MAX when the next step would overflow. Termination: naive saturation would re-report the top level forever (the emit branch doesn't advance counts_index). Since no bucket value ever equals INT64_MAX, pinning next_value_reporting_level_lowest_equivalent to INT64_MAX as well makes the iter->value >= level test stop firing, so the iterator drains remaining buckets and terminates.

Verification (ASan+UBSan)

  • Linear (units=2^62) and log (base 2) over an INT64_MAX histogram: no trap, terminate (2 and 64 steps). Normal-range iteration unchanged. Regression test added; full suite green.

🤖 Generated with Claude Code

Found by adversarial review during the ClusterFuzzLite effort. For a histogram
whose highest_trackable_value is near INT64_MAX, the top bucket's
lowest_equivalent_value + size_of_equivalent_value_range exceeds INT64_MAX,
which is signed-overflow UB. This poisoned hdr_next_non_equivalent_value ->
highest_equivalent_value (hence hdr_max, hdr_value_at_percentile,
hdr_percentiles_print) and the move_next iterator step (hence hdr_stddev and
any full iteration -- the all-values iterator visits the top bucket regardless
of counts). All in-contract and reachable via the read path on a decoded
histogram.

Saturate both additions at INT64_MAX instead of overflowing. Verified under
ASan+UBSan: hdr_init(1, INT64_MAX, 3) + record(INT64_MAX) then hdr_max /
percentile / stddev / full iteration / percentiles_print no longer trap and
return representable values. Added a regression test.

Found-by: adversarial review during the ClusterFuzzLite fuzzing effort
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…variant)

Adversarial review of the initial saturation found that deriving
highest_equivalent_value as hdr_next_non_equivalent_value(v) - 1 yielded
INT64_MAX-1 for the top bucket, so hdr_max of a histogram that recorded
INT64_MAX returned a value BELOW it (violating value <= highest_equivalent),
and disagreed with move_next() which saturates the iterator's
highest_equivalent_value to INT64_MAX. Give highest_equivalent_value its own
overflow-aware computation clamping to INT64_MAX so hdr_max / value_at_percentile
and the iterator agree. Strengthen the regression test to pin the saturated
values (hdr_max == INT64_MAX, hdr_next_non_equivalent_value == INT64_MAX -- the
latter distinguishes the fix without a sanitizer, since the buggy code wraps to
INT64_MIN).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@fcostaoliveira
fcostaoliveira force-pushed the fix/top-bucket-value-range-overflow branch from cedc46a to 92a219f Compare August 28, 2026 09:54
@fcostaoliveira
fcostaoliveira force-pushed the fix/iterator-reporting-level-overflow branch from 49b2366 to 655ad34 Compare August 28, 2026 10:31
fcostaoliveira and others added 2 commits August 28, 2026 15:38
Found by adversarial review during the ClusterFuzzLite effort. For a histogram
whose highest_trackable_value is near INT64_MAX, the linear and logarithmic
iterators advanced their reporting level with 'next += value_units_per_bucket'
(iter_linear_next) and 'next *= (int64_t)log_base' (log_iter_next), both of
which overflow int64 -> signed-overflow UB, then a negative left-shift when the
wrapped value flows into lowest_equivalent_value.

Saturate the reporting level at INT64_MAX when the next step would overflow.
Crucially, also pin next_value_reporting_level_lowest_equivalent to INT64_MAX
in that case: no bucket value ever equals INT64_MAX, so the 'iter->value >=
level' emit test can no longer fire and the iterator drains its remaining
buckets and terminates rather than re-reporting the saturated level forever.

Verified under ASan+UBSan: linear (value_units_per_bucket=2^62) and log (base 2)
over an INT64_MAX-range histogram no longer trap and terminate (2 and 64 steps);
normal-range iteration is unchanged. Regression test added.

Stacked on #148 (whose move_next fix the iterators depend on).

Found-by: adversarial review during the ClusterFuzzLite fuzzing effort
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adversarial review of the reporting-level saturation found two more iterator
issues in the same functions:

- next_value_greater_than_reporting_level_upper_bound peeked
  hdr_value_at_index(h, counts_index + 1); at the last bucket that reads one
  past counts[], and value_from_index shifts into the sign bit -> signed-shift
  UB for a near-INT64_MAX range. It was masked before this PR (the reporting
  level overflowed first); saturating that unmasked it. Require counts_index+1
  to be in range before peeking.

- Degenerate iterator parameters looped forever: value_units_per_bucket <= 0
  (level never advances) and log_base <= 1 (cast to 0/1, *= never advances).
  Pin the level (and its lowest-equivalent) to INT64_MAX in those cases too so
  the emit test cannot re-fire and the iterator drains and terminates. The
  degenerate checks are ordered first so the guard arithmetic (INT64_MAX - vpb,
  INT64_MAX / base) can't itself overflow or divide by zero.

Verified under ASan+UBSan: the peek repro (hdr_init(1, 1<<62, 1), record
1<<62, linear vpb=INT64_MAX-1 / log base 2) and the degenerate cases (vpb=0,
log_base 1.0/0.5/1.5) all terminate with no trap; normal iteration unchanged.
Regression tests extended.

Found-by: adversarial review during the ClusterFuzzLite fuzzing effort
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@fcostaoliveira
fcostaoliveira force-pushed the fix/iterator-reporting-level-overflow branch from 655ad34 to e478997 Compare August 28, 2026 14:41
@github-actions

github-actions Bot commented Sep 2, 2026 •

Copy link
Copy Markdown

🤖 Automated first-pass review — a human maintainer's review is still required before merge.

The core saturation argument holds: bucket values are sub_bucket_index << shift with a 32-bit sub-bucket index, so no iter->value can ever equal INT64_MAX, and pinning next_value_reporting_level_lowest_equivalent there does make the emit test stop firing and the iterator drain. No struct layout or public signature change, so no SOVERSION question here.

Four things worth a look before merging:

The two log_base guards disagree. hdr_iter_log_init accepts any finite base > 1.0, but log_iter_next tests (int64_t) log_base <= 1, so log_base = 1.5 passes init and then pins to INT64_MAX on the first advance — one step, then silent truncation. Better than the infinite loop it replaces, but it bakes in the (int64_t) cast. I couldn't verify this myself, but my understanding is the Java LogarithmicIterator keeps the reporting level as a double and multiplies by logBase directly, so a non-integer base works there. If that's right, the cast is the actual divergence and this PR makes it a quiet early stop rather than a hang. Worth checking against the Java implementation before settling on the guard.

peek_next_value_from_index is now a copy of hdr_value_at_index's body with saturation bolted on. The 1023-step test explains why the simpler counts_index + 1 < counts_len bound was rejected, but two copies of the index→value math — one saturating, one not — will diverge eventually. A shared static helper taking the saturation flag, or saturating inside hdr_value_at_index itself, would be safer.

Degenerate init params now yield a truncated iteration instead of looping. These init functions return void, so there's no error channel; if Java rejects valueUnitsPerBucket < 1 outright, a note in hdr_histogram.h about the silent-drain behaviour would be worth adding.

On verification: the per-PR ClusterFuzzLite gate is ASan only (UBSan is the weekly batch), the linear fuzz target clamps vpb to (0, highest], and there's no log-iterator target at all — so none of the new guards are exercised by CI. Your local ASan+UBSan run is what's actually backing this. Extending the fuzz target to the ranges this PR guards would be a natural follow-up. The tests only assert termination under a 1e6 bound; pinning the stated step counts (2 and 64) would catch a regression more loudly.

Minor: test/hdr_histogram_test.c adds a second #include <math.h> next to the existing one.

Comment thread test/hdr_histogram_test.c
Comment on lines +619 to +622
while (hdr_iter_next(&iter))
{
mu_assert("linear iterator must terminate", ++steps < 1000000);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like iter.value_iterated_to remains at the previous reporting level after saturation (instead of being updated to INT64_MAX)
Should this test also assert that the final reported value is INT64_MAX?

If so, it should also apply for the log iterator test too

@fcostaoliveira
fcostaoliveira changed the base branch from fix/top-bucket-value-range-overflow to main September 18, 2026 10:00
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.

3 participants