Fix UBSAN signed integer negation in `countDigits` for decimal values by groeneai · Pull Request #103225 · ClickHouse/ClickHouse · GitHub
Skip to content

Fix UBSAN signed integer negation in countDigits for decimal values#103225

Merged
alexey-milovidov merged 3 commits into
ClickHouse:masterfrom
groeneai:fix-stid-1751-countdigits-ubsan
May 19, 2026
Merged

Fix UBSAN signed integer negation in countDigits for decimal values#103225
alexey-milovidov merged 3 commits into
ClickHouse:masterfrom
groeneai:fix-stid-1751-countdigits-ubsan

Conversation

@groeneai

@groeneai groeneai commented Apr 21, 2026

Copy link
Copy Markdown
Contributor

The decimal branch of countDigits::execute computed the absolute value of a negative decimal by applying unary minus directly to the signed native integer (-value) before casting to unsigned. When the underlying value equals the signed minimum (for example INT32_MIN for Decimal32), this triggers undefined behavior — the result cannot be represented in the signed type. The integer (non-decimal) branch a few lines below already used the correct cast-to-unsigned-then-negate pattern.

The fuzzer caught this on master as STID 1751-507c in the AST fuzzer (arm_asan_ubsan) check — UBSAN flagged:

/ClickHouse/src/Functions/countDigits.cpp:138:93: runtime error:
negation of -2147483648 cannot be represented in type 'int';
cast to an unsigned type to negate this value to itself

Over the last 30 days the same diagnostic appeared on master and on two unrelated PR runs, all pointing at the same line.

Relevant CI report:
https://s3.amazonaws.com/clickhouse-test-reports/json.html?REF=master&sha=56bc5847a60449e7339fcdeecc284c19d276ab06&name_0=MasterCI&name_1=AST%20fuzzer%20%28arm_asan_ubsan%29

Fix

Cast the signed value to NativeT (the unsigned equivalent of the column's native integer type) before applying unary minus, so the arithmetic is well-defined modular arithmetic rather than undefined signed overflow. The outer cast back to NativeT suppresses the -Wimplicit-int-conversion-on-negation diagnostic that fires for narrower types, which are subject to integer promotion to int before the unary minus. Both the decimal and integer branches now use the same symmetric pattern.

Regression test

04107_count_digits_int_min_ubsan.sql constructs Decimal{32,64,128,256} columns with the signed minimum planted into their underlying native integer via reinterpret — the normal CAST/toDecimalXX paths reject values outside the precision range, so reinterpret is the only way to exercise the fuzzer-discovered path from SQL. The test also exercises the symmetric Int8/Int16/.../Int256 paths at their signed minima to guard against regressions in the integer branch.

Changelog category (leave one):

  • CI Fix or Improvement (changelog entry is not required)

Changelog entry (a user-readable short description of the changes that goes into CHANGELOG.md):

Documentation entry for user-facing changes

  • Documentation is written (mandatory for new features)

Version info

  • Merged into: 26.5.1.790

@groeneai

Copy link
Copy Markdown
Contributor Author

@groeneai

Copy link
Copy Markdown
Contributor Author

cc @rschu1ze @alexey-milovidov — could you review this? It's a small UBSAN fix for countDigits on Decimal* values: the decimal branch negated the signed native integer before casting to unsigned, which is UB on the signed minimum. The fix mirrors the existing integer-branch pattern (cast to unsigned first, then negate). Caught on master as STID 1751-507c in AST fuzzer (arm_asan_ubsan).

@alexey-milovidov alexey-milovidov added the can be tested Allows running workflows for external contributors label Apr 21, 2026
@alexey-milovidov alexey-milovidov self-assigned this Apr 21, 2026
@clickhouse-gh

clickhouse-gh Bot commented Apr 21, 2026

Copy link
Copy Markdown
Contributor

Workflow [PR], commit [c25d1cc]

Summary:

job_name test_name status info comment
Stateless tests (amd_llvm_coverage, ParallelReplicas, s3 storage, parallel) FAIL
02180_group_by_lowcardinality FAIL cidb

AI Review

Summary

This PR fixes undefined behavior in countDigits for negative decimal values at signed minimum by switching to the safe cast-to-unsigned-then-negate pattern, and adds focused regression coverage for integer and decimal minimum-value cases (including non-constant column execution). The implementation matches the reported root cause, and the current diff does not show unresolved contract violations.

Final Verdict

Status: ✅ Approve

@clickhouse-gh clickhouse-gh Bot added the pr-ci label Apr 21, 2026
@alexey-milovidov

Copy link
Copy Markdown
Member

@groeneai, we fixed an intermittent failure of performance tests, please update the branch.

The decimal branch of `countDigits::execute` computed the absolute value of a
negative decimal by applying unary minus directly to the signed native integer
(`-value`) before casting to unsigned. When the underlying value equals the
signed minimum (for example `INT32_MIN` for `Decimal32`), this triggers
undefined behavior: the result cannot be represented in the signed type. The
integer (non-decimal) branch a few lines below already used the correct
cast-to-unsigned-then-negate pattern.

The fuzzer caught this on master as STID 1751-507c in the `AST fuzzer
(arm_asan_ubsan)` check — UBSAN flagged `negation of -2147483648 cannot be
represented in type 'int'` at `src/Functions/countDigits.cpp:138`. Over the
last 30 days the same diagnostic appeared on master and on two unrelated PR
runs, all pointing at the same line.

The fix is to cast the signed value to `NativeT` (the unsigned equivalent of
the column's native integer type) before applying unary minus, so that the
arithmetic is well-defined modular arithmetic rather than undefined signed
overflow. The outer cast back to `NativeT` suppresses the
`-Wimplicit-int-conversion-on-negation` diagnostic that fires for narrower
types, which are subject to integer promotion to `int` before the unary minus.
Both the decimal and integer branches now use the same symmetric pattern.

A regression test `04107_count_digits_int_min_ubsan.sql` constructs
`Decimal{32,64,128,256}` columns with the signed minimum in their underlying
native integer via `reinterpret` (the normal `CAST`/`toDecimalXX` paths reject
values outside the precision range), which exercises the previously UB-prone
branch for every decimal width and the symmetric `Int8`/`Int16`/.../`Int256`
path.

Relevant CI report:
https://s3.amazonaws.com/clickhouse-test-reports/json.html?REF=master&sha=56bc5847a60449e7339fcdeecc284c19d276ab06&name_0=MasterCI&name_1=AST%20fuzzer%20%28arm_asan_ubsan%29
@groeneai groeneai force-pushed the fix-stid-1751-countdigits-ubsan branch from 4158ed5 to bb11cb5 Compare April 26, 2026 20:47
@groeneai

Copy link
Copy Markdown
Contributor Author

@alexey-milovidov Rebased onto current master 28be9d79738 — clean rebase, no conflicts. New HEAD: bb11cb5ec6b. CI starting fresh, should pick up the performance test fix you mentioned. APPROVED review preserved across force-push.

@alexey-milovidov

Copy link
Copy Markdown
Member

This was fixed by #105146. Let's update the branch.

@clickhouse-gh

clickhouse-gh Bot commented May 18, 2026

Copy link
Copy Markdown
Contributor

LLVM Coverage Report

Metric Baseline Current Δ
Lines 84.20% 84.20% +0.00%
Functions 91.40% 91.40% +0.00%
Branches 76.60% 76.60% +0.00%

Changed lines: 100.00% (8/8) · Uncovered code

Full report · Diff report

@alexey-milovidov alexey-milovidov merged commit 0d07a76 into ClickHouse:master May 19, 2026
163 of 167 checks passed
@robot-ch-test-poll1 robot-ch-test-poll1 added the pr-synced-to-cloud The PR is synced to the cloud repo label May 19, 2026
alexey-milovidov added a commit to vvo/ClickHouse that referenced this pull request May 19, 2026
…ClickHouse#103225)

The decimal branch of `countDigits::execute` computed the absolute value of a
negative decimal by applying unary minus directly to the signed native integer
(`-value`) before casting to unsigned. When the underlying value equals the
signed minimum (for example `INT32_MIN` for `Decimal32`), this triggers
undefined behavior: the result cannot be represented in the signed type. The
integer (non-decimal) branch a few lines below already used the correct
cast-to-unsigned-then-negate pattern.

The fuzzer caught this on master as STID 1751-507c in the `AST fuzzer
(arm_asan_ubsan)` check — UBSAN flagged `negation of -2147483648 cannot be
represented in type 'int'` at `src/Functions/countDigits.cpp:138`. Over the
last 30 days the same diagnostic appeared on master and on two unrelated PR
runs, all pointing at the same line.

The fix is to cast the signed value to `NativeT` (the unsigned equivalent of
the column's native integer type) before applying unary minus, so that the
arithmetic is well-defined modular arithmetic rather than undefined signed
overflow. The outer cast back to `NativeT` suppresses the
`-Wimplicit-int-conversion-on-negation` diagnostic that fires for narrower
types, which are subject to integer promotion to `int` before the unary minus.
Both the decimal and integer branches now use the same symmetric pattern.

A regression test `04107_count_digits_int_min_ubsan.sql` constructs
`Decimal{32,64,128,256}` columns with the signed minimum in their underlying
native integer via `reinterpret` (the normal `CAST`/`toDecimalXX` paths reject
values outside the precision range), which exercises the previously UB-prone
branch for every decimal width and the symmetric `Int8`/`Int16`/.../`Int256`
path.

Relevant CI report:
https://s3.amazonaws.com/clickhouse-test-reports/json.html?REF=master&sha=56bc5847a60449e7339fcdeecc284c19d276ab06&name_0=MasterCI&name_1=AST%20fuzzer%20%28arm_asan_ubsan%29

Co-authored-by: Groene AI <270696204+groeneai@users.noreply.github.com>
Co-authored-by: Alexey Milovidov <milovidov@clickhouse.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

can be tested Allows running workflows for external contributors pr-ci pr-synced-to-cloud The PR is synced to the cloud repo

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants