Skip to content

util: INT64_MIN is UB in four Rational operations and aborts in a fifth #537

Description

@Yaraslaut

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:

  1. 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.
  2. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: utilSubsystem: utilbugSomething isn't workingtriage: validWell-framed; implement as written

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions