ソースを参照

Better `from_chars` implementation (Fix #2475)

yhirose 3 ヶ月 前
親
コミット
7307c41aa8
2 ファイル変更、166 行追加、10 行削除
  1. 62 10
      httplib.h
  2. 104 0
      test/test.cc

+ 62 - 10
httplib.h

@@ -686,18 +686,70 @@ inline from_chars_result<T> from_chars(const char *first, const char *last,
   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,
                                             double &value) {
-  std::string s(first, last);
-  char *endptr = nullptr;
-  errno = 0;
-  value = std::strtod(s.c_str(), &endptr);
-  if (endptr == s.c_str()) { return {first, std::errc::invalid_argument}; }
-  if (errno == ERANGE) {
-    return {first + (endptr - s.c_str()), std::errc::result_out_of_range};
-  }
-  return {first + (endptr - s.c_str()), std::errc{}};
+  value = 0.0;
+  const char *p = first;
+
+  // Each 1eN is exactly representable, so a single final division by the
+  // matching entry yields a correctly-rounded result.
+  static const double powers_of_ten[] = {
+      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);
+  }
+
+  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) {

+ 104 - 0
test/test.cc

@@ -638,6 +638,75 @@ TEST(TrimTests, TrimStringTests) {
   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) {
   // Simple case without quality values
   std::vector<std::string> result1;
@@ -745,6 +814,41 @@ TEST(ParseAcceptHeaderTest, SpecialCases) {
   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) {
   std::vector<std::string> result;