bank/gui: bound parseMinor's input so an out-of-range amount is rejected rather than undefined (fixes #663) - #676
Conversation
… rejected rather than undefined (fixes #663) `bankgui::fmt::parseMinor` guarded only `!ok || major < 0.0` and then cast `major * scale + 0.5` to `std::int64_t`. `QString::toDouble` accepts `1e30`, `inf` and `nan`, none of the six call sites bounds its input, and five of them are GUI controllers reading a QML text field that carries no validator -- so every amount a user types reached an out-of-range floating-to-integer conversion, which is undefined behaviour, not a large number. Reproduced on 563502a with the header as it stood, clang 22.1.8, `-fsanitize=undefined -fno-sanitize-recover=undefined`: examples/bank/gui/controllers/Format.hpp:76:38: runtime error: 1e+32 is outside the range of representable values of type 'long' and without a sanitizer, `parseMinor("1e30")`, `parseMinor("inf")`, `parseMinor("nan")` and `parseMinor("9.3e16")` each returned `-9223372036854775808`, which every call site then fed through `.value_or(0)` into a balance. The fix rejects the input rather than clamping the result: `std::nullopt` is what the function already returns for unparseable text and for negatives, and every caller already handles it. The bound is checked on the scaled value against `0x1p63` rather than on `text`'s value against `numeric_limits<int64_t>::max() / scale`, because the latter is not exact -- `max()` is 2^63-1, which is not representable as a `double` and converts *upwards* to 2^63, and dividing that by `scale` rounds again. 2^63 is exactly representable, and `double` arithmetic cannot trap, so scaling first and comparing against it is the one form of the bound with no rounding in it. The comparison is written negated so that a NaN -- which reaches this point because `nan < 0.0` is false -- is rejected rather than admitted. Verified: a boundary sweep of 24576 doubles straddling the accept/reject edge for `decimals` 0, 1 and 2, under `-fsanitize=undefined -fno-sanitize-recover=undefined`, accepts exactly the values whose scaled form is below 2^63 and raises no diagnostic (12288 accepted, 12288 rejected). The new `tests/gui/test_bank_gui_format.cpp` joins `bank_gui_tests`, which links Qt6::Core and needs no engine or platform plugin. Its cases assert on the returned `optional`, not on the arithmetic: under UBSan the unfixed function aborts rather than returning a wrong answer, so a test that checked the value would report the defect as a crash on one configuration and as nothing at all on the others. Against the pre-fix header the suite fails 7 assertions in 2 of its 4 test cases (exit 42) without a sanitizer, and aborts on the first `1e30` case with one. The `+ 0.5` rounding note in #663 is labelled weak by the filer and is a separate question; it is pinned by a test here and otherwise left alone rather than folded into a UB fix. One consequence of that is worth stating rather than leaving to be found: the old line carried a `bugprone-incorrect-roundings` finding ("casting (double + 0.5) to integer leads to incorrect rounding; consider using lround"), and splitting the expression to introduce the bound stops that check matching. The rounding is unchanged, the check no longer says so, and the question it was pointing at is not settled here. Filed separately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
Runner verificationThe fix is right, and it corrects a bound I suggested that was subtly wrong. My triage of #663 proposed
That is the kind of correction I want from a lane rather than compliance. I verified the guard's edge behaviour myself, under The NaN case is the one worth pointing at: And the bug was more reachable than the ticket said. The before/after proof is the right shape — the new test compiled against the header extracted from The 24576-double sweep across the accept/reject edge for three #664 — handed back, and the hand-back is correctI have closed it Building the widened regex, getting it green, then running the check that should have come first and reverting all of it is the expensive-but-correct order. The branch touches no The tree-wide numbers survive the retraction and belong to #677/#580: 657 findings in 159 headers newly admitted, 633 of them in 150 Not verified by me: the 5577/423-TU sweep, the 🤖 Generated with Claude Code |
Runner: the red legs are environmental, re-run requestedBoth runs are complete, so I read every failing job rather than inferring. Failing step, per leg: Not one of them is a compile, a test or a lint finding. They are package installs, a vcpkg checkout, and a dependency-cache configure — the same class as #672, which now has four distinct upstreams recorded in one day. The two signatures seen here:
Why I am confident this is not the branches: master's own 14:27Z run was Re-run requested. If a leg comes back red with a compile or test error, that is a real finding and I will treat it as one. 🤖 Generated with Claude Code |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Batch of two, of which one lands and one is handed back as mis-framed.
1e30in any amount field is UB #663 — fixed. Undefined behaviour on every bank GUI amount.Everything below was measured on this box: clang 22.1.8 (CI pins
CLANG_VERSION: "22"), Qt 6.11.2, the clang-tidy job's own configure flags, from an empty build directory.#663 —
parseMinorconverted an unboundeddoubletostd::int64_texamples/bank/gui/controllers/Format.hppguarded only!ok || major < 0.0and then castmajor * scale + 0.5tostd::int64_t.QString::toDoubleaccepts1e30, and five of the six call sites are GUI controllers reading a QML field with no validator.Proof the test fails before the fix
Same test file, compiled against the header as it stands on
563502ab(extracted withgit show) and against the fixed one. No sanitizer:With
-fsanitize=undefined -fno-sanitize-recover=undefined, the same binary does not get as far as reporting an assertion:That is why every case asserts on the returned
optionalrather than on the arithmetic: the defect shows up as a wrong value on one configuration and as an abort on another, and only theoptionalassertion fails on both.Against the fixed header, under UBSan:
All tests passed (19 assertions in 4 test cases). Built and run for real, not only standalone —cmake --build build/clang-debug --target bank_gui_teststhen the binary:All tests passed (21 assertions in 5 test cases).Without a sanitizer, the pre-fix function returned
-9223372036854775808for1e30,1e300,inf,nanand9.3e16— the last is not an absurd magnitude, it is simply anything above ~9.2e16 major units once scaled by 100.infandnanare both accepted byQString::toDouble, andnanwalks through a< 0.0guard because every comparison against a NaN is false; neither is mentioned in the issue.Why the bound is not
max() / scaleThe issue and the triage both suggest
std::numeric_limits<std::int64_t>::max() / scale. That form is not exact, in the unsafe direction:max()is 2^63-1, which is not representable as adoubleand converts upwards to 2^63, and dividing that byscalerounds a second time. A value landing exactly on such a bound still produces an out-of-range conversion.So the check is on the scaled value against
0x1p63— 2^63 is exactly representable — and it is writtenif (!(minor < kMinorUnitsBound))so that a NaN is rejected rather than admitted.doublearithmetic cannot trap, so computing the product first costs nothing.Verified rather than argued: a sweep of 24576 doubles straddling the accept/reject edge for
decimals0, 1 and 2, under-fsanitize=undefined -fno-sanitize-recover=undefined, accepts exactly the values whose scaled form is below 2^63 and raises no diagnostic —checked=24576 accepted=12288 rejected=12288 -- no UB.One thing this change does that is not an improvement
The old line carried a clang-tidy finding, reportable today:
Splitting the expression to introduce the bound stops that check matching. The rounding behaviour is unchanged and is pinned by a test, but the check no longer points at it. The
+ 0.5question is labelled weak by the filer and was explicitly out of scope for a UB fix, so it is filed rather than folded — see the issue linked below.#664 — the premise does not hold for this repository's CI
The issue says the root
.clang-tidy'sHeaderFilterRegex: "include/morph/.*"means "findings in every header underexamples/**are discarded from every job", and that this is why #663 went unreported. The first half of the mechanism is exactly as described. The conclusion is not, and the difference is testable.What I measured first
I reproduced the job's configure (740 database entries, 423 in-workspace TUs, 282 under
examples/) and ran clang-tidy over all 423 with--header-filter='.*', one output file per TU, de-duplicating findings by (header, line, column, check). Whole tree, not a sample:So the filer's "49 findings across 12 headers" lower bound is 633 across 150 headers over the whole tree — about 13×. Top contributors are
cert-dcl59-cpp(226),readability-identifier-length(80),readability-identifier-naming(73),readability-convert-member-functions-to-static(34),readability-redundant-member-init(33),bugprone-unchecked-optional-access(28).The issue's warning against
.*is also confirmed, with a number:.*alone admits 1422 further findings in 62 vendored headers, 1174 of them in the Lightweight ORM. And an inclusive alternation of first-party roots would be wrong on arrival, not merely stale — a header filter is an unanchored search over an absolute path, and(include/morph|src|tests|examples)/matches 608 headers underbuild/clang-debug/_deps(244 glaze, 202 stdexec, 153 Lightweight), because FetchContent lays dependencies out as_deps/<name>-src/src/and_deps/<name>-src/include/.And then the part that changes the verdict
I built the widened config, the derived-polarity design and a behavioural gate for it, ran it green, and then ran the decisive check: does the pre-#664 regex actually hide anything from the one job that runs clang-tidy?
ci.yml'sclang-tidy-diffis the only job that analyses the tree. Its own filter step says so explicitly: "A changed header is never dropped -- a header is never a translation unit." A changed header therefore reachesclang-tidy-diff.py, which names it on the command line — and clang tooling analyses a header named on the command line as that translation unit's main file. Main-file diagnostics bypassHeaderFilterRegexentirely.Measured, on the unmodified pre-#664 config, with a diff containing nothing but one line of an
examples/**header:The finding is reported, under the regex the issue says discards it.
The mirror case, also on the unmodified config: a diff that changes only a
.cpp, whose TU includesexamples/bank/include/bank/models/transaction_model.hpp(5 findings when analysed as an include) reports nothing — becauseclang-tidy-diff.py's-line-filterdrops every finding in a file not in the diff, whatever the header filter says. Widening the regex does not change that either; I ran it both ways.So
HeaderFilterRegexhas no observable effect on the only job in this repository that runs clang-tidy over the tree. In the diff job:Which also disproves "#664 is why #663 sat unreported"
Analysing the pre-fix
Format.hppas its own TU under the pre-#664 narrow regex — i.e. exactly what CI does for a changed header:Three findings, one of them on line 76 — the line #663 is about — all reportable under the existing regex. Widening it adds only the three findings in the included
bank/core/types.hpp, which the line filter discards anyway. #663 went unreported because nobody put that line in a diff, not because a regex swallowed it. (No check reports the out-of-range conversion itself under either regex; clang-tidy would not have found #663 in any configuration.)So I landed nothing for #664
Widening the regex would have been a no-op in CI, and the gate I wrote for it would have asserted a property no job depends on — which is the failure mode
AGENTS.mdnames first. Handing it back with the measurement instead, so it can be re-framed around the gap that is real: a finding in an unchanged first-party header is reported by no job at all. Closing that needs a whole-file clang-tidy leg, not a regex; its bill is now costed at the numbers above. The correction is posted on #664 and the follow-up is filed.Reverted cleanly — this branch touches neither
.clang-tidynorscripts/, and nothing inci.yml(held by #673).Review reasoning, inline
/code-reviewand/simplifywere not used (they fork background agents this lane may not dispatch), so the review pass is written out here.12.34 -> 1234,0 -> 0," 7.5 " -> 750,1200atdecimals=0 -> 1200all unchanged, and pinned..value_or(0)or an explicitif (minor); the new rejections join the existing ones.0x1p63the right constant, not an approximation? Yes, and deliberately notmax()ormax()/scale— reasoning and the rounding argument are in the header comment, because that is where the next reader will be standing.clang-tidy-diff.pyover the branch diff initially reported twobugprone-unchecked-optional-accessfindings in the new test (the check does not see a Catch2REQUIREguard through the macro expansion, and.value()is flagged the same as*). Rewritten to compare theoptionaldirectly, which asserts both halves at once; re-run is clean (exit=0).clang-format --dry-run -Werrorclean over both touched files.major * scale + 0.5makes it reportreadability-math-missing-parenthesesand exit 1, so the check is reaching the header rather than skipping it.Local pre-push gates, all green:
check_tidy_suppression_scope.sh,check_qobject_moc_pairing.pyand its--self-test,check_bidi_controls.py,check_nolint_directives.sh,check_rung_filters.sh.Not verified
bank_gui, so the UBSan evidence here comes from a hand-built binary rather than from a leg. That is the filer's own second observation on bank GUI: parseMinor casts an unbounded double to int64_t;1e30in any amount field is UB #663 and is filed separately.bugprone-incorrect-roundingsfires on this line under CI's Catch2 3.4.0 — the finding is in a non-test TU, so the 3.16.0 macro-expansion skew should not apply, but I did not install 3.4.0 to confirm.Fixes #663. Refs #664.
🤖 Generated with Claude Code
https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW