From 7dc456208eb8944bca17a7fe9d71ff990e401958 Mon Sep 17 00:00:00 2001 From: Aizal Khan Date: Wed, 14 Oct 2026 22:14:28 +0530 Subject: [PATCH 1/3] fix unchecked float to integral conversion in detail::duration_cast --- include/fmt/chrono.h | 35 ++++++++++++++++++++++++++--------- test/chrono-test.cc | 9 +++++++++ 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/include/fmt/chrono.h b/include/fmt/chrono.h index 19571164..40a465a2 100644 --- a/include/fmt/chrono.h +++ b/include/fmt/chrono.h @@ -448,12 +448,6 @@ template <> struct locale_writer { } }; -template -using is_similar_arithmetic_type = - bool_constant<(std::is_integral::value && std::is_integral::value) || - (std::is_floating_point::value && - std::is_floating_point::value)>; - FMT_NORETURN inline void throw_duration_error() { FMT_THROW(format_error("cannot format duration")); } @@ -510,14 +504,37 @@ auto duration_cast(std::chrono::duration from) -> To { #endif } +// Mixed integer <-> float casts are not supported by safe_duration_cast. +// An integer to float conversion is always defined. template ::value)> + FMT_ENABLE_IF(std::is_integral::value&& + std::is_floating_point::value)> auto duration_cast(std::chrono::duration from) -> To { - // Mixed integer <-> float cast is not supported by safe duration_cast. return std::chrono::duration_cast(from); } +// Cast a floating-point duration to an integral one with a range check since +// converting an out-of-range or NaN value to an integer is undefined behavior. +template ::value&& + std::is_integral::value)> +auto duration_cast(std::chrono::duration from) -> To { +#if FMT_SAFE_DURATION_CAST + using to_rep = typename To::rep; + auto count = detail::duration_cast< + std::chrono::duration>(from) + .count(); + // max + 1 is a power of 2 so the cast to FromRep is exact even when max + // itself is rounded up by the conversion; the comparison is false for NaN. + auto upper = static_cast(max_value()) + 1; + auto lower = static_cast((std::numeric_limits::min)()); + if (!(count >= lower && count < upper)) throw_duration_error(); + return To(static_cast(count)); +#else + return std::chrono::duration_cast(from); +#endif +} + template auto to_time_t(sys_time time_point) -> std::time_t { // Cannot use std::chrono::system_clock::to_time_t since this would first diff --git a/test/chrono-test.cc b/test/chrono-test.cc index 818ec42f..fe9b4fa8 100644 --- a/test/chrono-test.cc +++ b/test/chrono-test.cc @@ -995,6 +995,15 @@ TEST(chrono_test, out_of_range) { // A floating-point day count that doesn't fit in int. auto fd = std::chrono::duration(1e300); EXPECT_THROW((void)fmt::format("{:%j}", fd), fmt::format_error); + // A floating-point time point whose seconds don't fit in time_t. + auto ftp = sys_time>(fd); + EXPECT_THROW((void)fmt::format("{:%T}", ftp), fmt::format_error); + auto fnan = sys_time>( + std::chrono::duration(std::numeric_limits::quiet_NaN())); + EXPECT_THROW((void)fmt::format("{:%T}", fnan), fmt::format_error); + auto flocal = fmt::local_time>( + std::chrono::duration(-1e300)); + EXPECT_THROW((void)fmt::format("{:%F}", flocal), fmt::format_error); } TEST(chrono_test, calendar_padding) { From c9b66420b513940081f82c46d3ad806513d83521 Mon Sep 17 00:00:00 2001 From: Aizal Khan Date: Sun, 25 Oct 2026 11:56:49 +0530 Subject: [PATCH 2/3] Check the truncated count in float to integral duration_cast --- include/fmt/chrono.h | 4 +++- test/chrono-test.cc | 27 +++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/include/fmt/chrono.h b/include/fmt/chrono.h index 40a465a2..f805911c 100644 --- a/include/fmt/chrono.h +++ b/include/fmt/chrono.h @@ -524,11 +524,13 @@ auto duration_cast(std::chrono::duration from) -> To { auto count = detail::duration_cast< std::chrono::duration>(from) .count(); + // The conversion truncates toward zero so check the truncated value. // max + 1 is a power of 2 so the cast to FromRep is exact even when max // itself is rounded up by the conversion; the comparison is false for NaN. auto upper = static_cast(max_value()) + 1; auto lower = static_cast((std::numeric_limits::min)()); - if (!(count >= lower && count < upper)) throw_duration_error(); + auto truncated = std::trunc(count); + if (!(truncated >= lower && truncated < upper)) throw_duration_error(); return To(static_cast(count)); #else return std::chrono::duration_cast(from); diff --git a/test/chrono-test.cc b/test/chrono-test.cc index fe9b4fa8..4f7eabd0 100644 --- a/test/chrono-test.cc +++ b/test/chrono-test.cc @@ -1006,6 +1006,33 @@ TEST(chrono_test, out_of_range) { EXPECT_THROW((void)fmt::format("{:%F}", flocal), fmt::format_error); } +TEST(chrono_test, float_to_integral_duration_cast) { + using fsec = std::chrono::duration; + // Values that truncate toward zero into range are valid. + EXPECT_EQ( + fmt::detail::duration_cast>(fsec(-0.5)) + .count(), + 0u); + auto min = (std::numeric_limits::min)(); + EXPECT_EQ( + fmt::detail::duration_cast>(fsec(min - 0.5)) + .count(), + min); + auto max = (std::numeric_limits::max)(); + EXPECT_EQ( + fmt::detail::duration_cast>(fsec(max + 0.5)) + .count(), + max); + if (FMT_SAFE_DURATION_CAST) { + EXPECT_THROW( + fmt::detail::duration_cast>(fsec(-1)), + fmt::format_error); + EXPECT_THROW( + fmt::detail::duration_cast>(fsec(min - 1.0)), + fmt::format_error); + } +} + TEST(chrono_test, calendar_padding) { EXPECT_EQ("05 ", fmt::format("{:5}", fmt::day(5))); EXPECT_EQ(" Jan", fmt::format("{:>5}", fmt::month(1))); From f501601c683957dfb3110f04958e913261841436 Mon Sep 17 00:00:00 2001 From: Aizal Khan Date: Mon, 26 Oct 2026 10:22:53 +0530 Subject: [PATCH 3/3] Keep the duration_cast fallback for custom representations --- include/fmt/chrono.h | 19 +++++++++++++++---- test/chrono-test.cc | 21 +++++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/include/fmt/chrono.h b/include/fmt/chrono.h index f805911c..71d2db58 100644 --- a/include/fmt/chrono.h +++ b/include/fmt/chrono.h @@ -448,6 +448,12 @@ template <> struct locale_writer { } }; +template +using is_similar_arithmetic_type = + bool_constant<(std::is_integral::value && std::is_integral::value) || + (std::is_floating_point::value && + std::is_floating_point::value)>; + FMT_NORETURN inline void throw_duration_error() { FMT_THROW(format_error("cannot format duration")); } @@ -504,12 +510,17 @@ auto duration_cast(std::chrono::duration from) -> To { #endif } -// Mixed integer <-> float casts are not supported by safe_duration_cast. -// An integer to float conversion is always defined. +template +using is_float_to_integral = bool_constant::value && + std::is_integral::value>; + template ::value&& - std::is_floating_point::value)> + FMT_ENABLE_IF( + !is_similar_arithmetic_type::value && + !is_float_to_integral::value)> auto duration_cast(std::chrono::duration from) -> To { + // Integer to float and custom representations are not supported by safe + // duration_cast. An integer to float conversion is always defined. return std::chrono::duration_cast(from); } diff --git a/test/chrono-test.cc b/test/chrono-test.cc index 4f7eabd0..55a4c665 100644 --- a/test/chrono-test.cc +++ b/test/chrono-test.cc @@ -1033,6 +1033,27 @@ TEST(chrono_test, float_to_integral_duration_cast) { } } +struct custom_rep { + long long value = 0; + custom_rep() = default; + custom_rep(long long v) : value(v) {} + operator long long() const { return value; } +}; + +namespace std { +template <> struct common_type { + using type = long long; +}; +template <> struct common_type { + using type = long long; +}; +} // namespace std + +TEST(chrono_test, custom_rep_duration_cast) { + auto d = std::chrono::duration(custom_rep(2500)); + EXPECT_EQ(fmt::detail::duration_cast(d).count(), 2); +} + TEST(chrono_test, calendar_padding) { EXPECT_EQ("05 ", fmt::format("{:5}", fmt::day(5))); EXPECT_EQ(" Jan", fmt::format("{:>5}", fmt::month(1)));