Better from_chars implementation (Fix #2475)

This commit is contained in:
yhirose
2026-06-17 23:21:36 -04:00
parent df0b7d243b
commit 7307c41aa8
2 changed files with 165 additions and 9 deletions
+61 -9
View File
@@ -686,18 +686,70 @@ inline from_chars_result<T> from_chars(const char *first, const char *last,
return {p, std::errc{}}; return {p, std::errc{}};
} }
// from_chars for double (simple wrapper for strtod) // from_chars for double (hand-written, locale-independent)
//
// The only double consumed by this library is the HTTP quality value, whose
// grammar is (RFC 9110 12.4.2):
// qvalue = ( "0" [ "." 0*3DIGIT ] ) / ( "1" [ "." 0*3("0") ] )
// i.e. a non-negative decimal with no sign, exponent, "inf"/"nan", or wide
// magnitude. So this parser recognizes exactly 1*DIGIT [ "." *DIGIT ] with
// '.' always the decimal separator (std::strtod would instead read it from the
// global C locale, mis-parsing q-values once an embedder calls
// setlocale(LC_ALL, "") into a comma-decimal locale). The caller range-checks
// the result to [0, 1], so inputs outside that range need not be distinguished
// here. Allocation-free, single pass, and free of the overflow/rounding edge
// cases that exponent and wide-range handling would introduce.
inline from_chars_result<double> from_chars(const char *first, const char *last, inline from_chars_result<double> from_chars(const char *first, const char *last,
double &value) { double &value) {
std::string s(first, last); value = 0.0;
char *endptr = nullptr; const char *p = first;
errno = 0;
value = std::strtod(s.c_str(), &endptr); // Each 1eN is exactly representable, so a single final division by the
if (endptr == s.c_str()) { return {first, std::errc::invalid_argument}; } // matching entry yields a correctly-rounded result.
if (errno == ERANGE) { static const double powers_of_ten[] = {
return {first + (endptr - s.c_str()), std::errc::result_out_of_range}; 1e0, 1e1, 1e2, 1e3, 1e4, 1e5, 1e6, 1e7, 1e8, 1e9,
1e10, 1e11, 1e12, 1e13, 1e14, 1e15, 1e16, 1e17, 1e18};
const int max_frac_digits =
static_cast<int>(sizeof(powers_of_ten) / sizeof(powers_of_ten[0])) - 1;
// Accumulate digits into a 64-bit integer and remember how many were
// fractional. Two independent caps keep this bounded and safe:
// * accumulation saturates before mantissa could overflow uint64_t, and
// * frac_digits is capped at max_frac_digits so it is always a valid index
// into powers_of_ten (without this an input like "0.000...0" would never
// grow mantissa, so the saturation cap alone would not bound it).
// Both caps only drop digits far beyond the precision a q-value needs; any
// value they would change is well outside [0, 1] and rejected by the caller.
uint64_t mantissa = 0;
int frac_digits = 0;
bool seen_digit = false;
const uint64_t limit = ((std::numeric_limits<uint64_t>::max)() - 9) / 10;
auto accumulate = [&](char c) {
if (mantissa <= limit) {
mantissa = mantissa * 10 + static_cast<uint64_t>(c - '0');
return true;
}
return false;
};
for (; p != last && '0' <= *p && *p <= '9'; ++p) {
seen_digit = true;
accumulate(*p);
} }
return {first + (endptr - s.c_str()), std::errc{}};
if (p != last && *p == '.') {
++p;
for (; p != last && '0' <= *p && *p <= '9'; ++p) {
seen_digit = true;
if (frac_digits < max_frac_digits && accumulate(*p)) { ++frac_digits; }
}
}
if (!seen_digit) { return {first, std::errc::invalid_argument}; }
value = static_cast<double>(mantissa) / powers_of_ten[frac_digits];
return {p, std::errc{}};
} }
inline bool parse_port(const char *s, size_t len, int &port) { inline bool parse_port(const char *s, size_t len, int &port) {
+104
View File
@@ -638,6 +638,75 @@ TEST(TrimTests, TrimStringTests) {
EXPECT_TRUE(detail::trim_copy("").empty()); EXPECT_TRUE(detail::trim_copy("").empty());
} }
TEST(FromCharsTest, Double) {
// detail::from_chars(double) recognizes exactly the HTTP quality-value
// grammar (RFC 9110 12.4.2): a non-negative decimal "1*DIGIT [ '.' *DIGIT ]"
// with no sign, exponent, or "inf"/"nan", parsed locale-independently.
auto parse = [](const std::string &s, double &v) {
return detail::from_chars(s.data(), s.data() + s.size(), v);
};
double v = -1.0;
// Representative quality values.
EXPECT_EQ(parse("0", v).ec, std::errc{});
EXPECT_DOUBLE_EQ(v, 0.0);
EXPECT_EQ(parse("1", v).ec, std::errc{});
EXPECT_DOUBLE_EQ(v, 1.0);
EXPECT_EQ(parse("0.8", v).ec, std::errc{});
EXPECT_DOUBLE_EQ(v, 0.8);
EXPECT_EQ(parse("0.001", v).ec, std::errc{});
EXPECT_DOUBLE_EQ(v, 0.001);
EXPECT_EQ(parse("1.000", v).ec, std::errc{});
EXPECT_DOUBLE_EQ(v, 1.0);
// A missing integer or fractional part is tolerated.
EXPECT_EQ(parse(".5", v).ec, std::errc{});
EXPECT_DOUBLE_EQ(v, 0.5);
EXPECT_EQ(parse("5.", v).ec, std::errc{});
EXPECT_DOUBLE_EQ(v, 5.0);
// Values outside [0, 1] still parse; the caller range-checks them.
EXPECT_EQ(parse("123.456", v).ec, std::errc{});
EXPECT_DOUBLE_EQ(v, 123.456);
// Stops at the first byte that is not part of the number and reports where.
std::string trailing = "0.9, text/html";
auto r = parse(trailing, v);
EXPECT_EQ(r.ec, std::errc{});
EXPECT_DOUBLE_EQ(v, 0.9);
EXPECT_EQ(*r.ptr, ',');
// Sign and exponent are NOT part of the grammar: '+'/'-' are rejected
// outright, and an 'e'/'E' simply ends the number.
EXPECT_EQ(parse("-3.25", v).ec, std::errc::invalid_argument);
EXPECT_EQ(parse("+2.5", v).ec, std::errc::invalid_argument);
std::string exp_input = "1.5e3";
r = parse(exp_input, v);
EXPECT_EQ(r.ec, std::errc{});
EXPECT_DOUBLE_EQ(v, 1.5);
EXPECT_EQ(*r.ptr, 'e');
// Invalid inputs.
EXPECT_EQ(parse("", v).ec, std::errc::invalid_argument);
EXPECT_EQ(parse(".", v).ec, std::errc::invalid_argument);
EXPECT_EQ(parse("abc", v).ec, std::errc::invalid_argument);
EXPECT_EQ(parse("nan", v).ec, std::errc::invalid_argument);
EXPECT_EQ(parse("inf", v).ec, std::errc::invalid_argument);
// Pathological but well-formed inputs must stay bounded (no overflow, no
// out-of-bounds table access) and stay within [0, 1] for the caller.
EXPECT_EQ(parse(std::string("0.") + std::string(500, '0'), v).ec,
std::errc{});
EXPECT_DOUBLE_EQ(v, 0.0);
EXPECT_EQ(parse(std::string("0.") + std::string(500, '9'), v).ec,
std::errc{});
EXPECT_GE(v, 0.0);
EXPECT_LE(v, 1.0);
EXPECT_EQ(parse(std::string(500, '9'), v).ec, std::errc{});
EXPECT_GT(v, 1.0); // huge integer: finite, > 1, so the caller rejects it
}
TEST(ParseAcceptHeaderTest, BasicAcceptParsing) { TEST(ParseAcceptHeaderTest, BasicAcceptParsing) {
// Simple case without quality values // Simple case without quality values
std::vector<std::string> result1; std::vector<std::string> result1;
@@ -745,6 +814,41 @@ TEST(ParseAcceptHeaderTest, SpecialCases) {
EXPECT_EQ(no_space_result[2], "text/plain"); EXPECT_EQ(no_space_result[2], "text/plain");
} }
TEST(ParseAcceptHeaderTest, QualityValueLocaleIndependence) {
// Quality values always use '.' as the decimal separator, so parsing must
// not depend on the process locale. An embedding application may have
// switched to a locale that uses ',' via setlocale(LC_ALL, "").
const char *cur = std::setlocale(LC_NUMERIC, nullptr);
std::string saved = cur ? cur : "C";
const char *comma_locales[] = {"de_DE.UTF-8", "de_DE.utf8", "nl_NL.UTF-8",
"fr_FR.UTF-8"};
bool switched = false;
for (const auto loc : comma_locales) {
if (std::setlocale(LC_NUMERIC, loc) != nullptr &&
std::localeconv()->decimal_point[0] == ',') {
switched = true;
break;
}
}
if (!switched) {
std::setlocale(LC_NUMERIC, saved.c_str());
GTEST_SKIP() << "no comma-decimal locale available on this host";
}
// The higher-weighted type appears later in the list, so a correct parse
// must reorder it ahead of the earlier, lower-weighted one. A locale-
// sensitive parse reads both weights as 0 and leaves the original order.
std::vector<std::string> result;
EXPECT_TRUE(detail::parse_accept_header(
"application/json;q=0.1,text/html;q=0.9", result));
ASSERT_EQ(result.size(), 2U);
EXPECT_EQ(result[0], "text/html");
EXPECT_EQ(result[1], "application/json");
std::setlocale(LC_NUMERIC, saved.c_str());
}
TEST(ParseAcceptHeaderTest, InvalidCases) { TEST(ParseAcceptHeaderTest, InvalidCases) {
std::vector<std::string> result; std::vector<std::string> result;