Part of the sweep tracked in #518. Finding F19.
Summary
Rational(std::int64_t whole, DecimalPlaces) (rational.hpp:391) is the only constructor that does not call canonicalise(), so it is the one entry point that admits numerator == INT64_MIN:
constexpr Rational(std::int64_t whole, DecimalPlaces wantedPrecision) noexcept
: numerator{whole}, decimalPlaces{detail::clampDecimalPlaces(wantedPrecision.value)} {}
Four downstream sites then negate it directly, and a fifth hands it to std::gcd.
Verification status
Reproduced. Revision: master @ 4017228d. Built with clang 21 / libstdc++ 16 under UBSan:
rational.hpp:972:37: runtime error: negation of -9223372036854775808 cannot be
represented in type 'std::int64_t' [abs()]
rational.hpp:489:35: runtime error: negation of -9223372036854775808 ... [operator-]
rational.hpp:499:66: runtime error: negation of -9223372036854775808 ... [reciprocal()]
rational.hpp:896:60: runtime error: negation of -9223372036854775808 ... [mulWouldOverflow]
/usr/include/c++/16/numeric:131: __abs_r(_Tp): Assertion
'__val != __int_traits<_Res>::__min' failed.
Input:
morph::math::Rational bad{std::numeric_limits<std::int64_t>::min(), DecimalPlaces{2}};
auto a = morph::math::abs(bad);
auto n = -bad;
auto r = bad.reciprocal();
auto p = bad * Rational{2, DecimalPlaces{2}};
reciprocal also produces a wrong answer where it does not trap: it returns 1/INT64_MAX — off by one ulp of denominator, with no clamp logged.
compareForSaturation (rational.hpp:750) reaches operator- via *this <=> -rhs, so the +=/-= saturation path is a fifth entry point.
Not verified: I did not check whether any in-tree caller constructs a Rational from INT64_MIN; this is reachable through the public constructor, not necessarily reached today. The numerator member is also public (rational.hpp:377-383), so it can be set directly.
Why this is a defect and not a design choice
docs/spec/util/rational.md acknowledges "INT64_MIN negation hazards" but lists three of the five sites, and none is fixed. The std::gcd case is the worst: it is a library precondition violation, not merely UB-on-overflow, so with _GLIBCXX_ASSERTIONS off it silently returns garbage and the product is quietly wrong rather than saturated.
The repository already has the correct helper — detail::absU64 — and already used it to fix exactly this pattern in formatRationalDecimal (morph#496). The sibling sites were not swept.
Suggested fix
Two independent changes:
- Make the whole-integer constructor canonicalise like every other one. A delegating constructor keeps it
constexpr and closes every path at once, matching what setWire already does:
constexpr Rational(std::int64_t whole, DecimalPlaces p) noexcept
: Rational{Numerator{whole}, Denominator{1}, p} {}
canonicalise() already clamps INT64_MIN → -INT64_MAX and logs via reportClamp.
- Defence in depth for the public-member case: route all five negation sites through
detail::absU64.
What would change the verdict
- Close it if
Rational{INT64_MIN, dp} is declared a precondition violation — but then the constructor should reject or clamp rather than admit it silently.
- Regression test, UBSan-gated: construct from
INT64_MIN and exercise abs, unary -, *, reciprocal, checkedMul. tests/test_quantity.cpp:664 pins only the rendering path; tests/test_rational_checked.cpp:362,386 pin only the canonicalise clamp. Note this can only fail CI once the UBSan leg is able to fail at all — see the sanitizer finding.
Part of the sweep tracked in #518. Finding F19.
Summary
Rational(std::int64_t whole, DecimalPlaces)(rational.hpp:391) is the only constructor that does not callcanonicalise(), so it is the one entry point that admitsnumerator == INT64_MIN:Four downstream sites then negate it directly, and a fifth hands it to
std::gcd.Verification status
Reproduced. Revision:
master@4017228d. Built with clang 21 / libstdc++ 16 under UBSan:Input:
morph::math::Rational bad{std::numeric_limits<std::int64_t>::min(), DecimalPlaces{2}}; auto a = morph::math::abs(bad); auto n = -bad; auto r = bad.reciprocal(); auto p = bad * Rational{2, DecimalPlaces{2}};reciprocalalso produces a wrong answer where it does not trap: it returns1/INT64_MAX— off by one ulp of denominator, with no clamp logged.compareForSaturation(rational.hpp:750) reachesoperator-via*this <=> -rhs, so the+=/-=saturation path is a fifth entry point.Not verified: I did not check whether any in-tree caller constructs a
RationalfromINT64_MIN; this is reachable through the public constructor, not necessarily reached today. Thenumeratormember is also public (rational.hpp:377-383), so it can be set directly.Why this is a defect and not a design choice
docs/spec/util/rational.mdacknowledges "INT64_MINnegation hazards" but lists three of the five sites, and none is fixed. Thestd::gcdcase is the worst: it is a library precondition violation, not merely UB-on-overflow, so with_GLIBCXX_ASSERTIONSoff it silently returns garbage and the product is quietly wrong rather than saturated.The repository already has the correct helper —
detail::absU64— and already used it to fix exactly this pattern informatRationalDecimal(morph#496). The sibling sites were not swept.Suggested fix
Two independent changes:
constexprand closes every path at once, matching whatsetWirealready does:canonicalise()already clampsINT64_MIN→-INT64_MAXand logs viareportClamp.detail::absU64.What would change the verdict
Rational{INT64_MIN, dp}is declared a precondition violation — but then the constructor should reject or clamp rather than admit it silently.INT64_MINand exerciseabs, unary-,*,reciprocal,checkedMul.tests/test_quantity.cpp:664pins only the rendering path;tests/test_rational_checked.cpp:362,386pin only thecanonicaliseclamp. Note this can only fail CI once the UBSan leg is able to fail at all — see the sanitizer finding.