From bd455f8b9168733cc9bfc204058abaced365f235 Mon Sep 17 00:00:00 2001 From: yhirose Date: Wed, 1 Jul 2026 23:00:48 -0400 Subject: [PATCH] Add locale-independent ASCII classification helpers (Fix #2482, Fix #2483) std::isalnum and std::isdigit consult the global C locale, so a byte like 0xC5 can classify as alphanumeric once an embedder calls setlocale() (observed on macOS). HTTP grammars are defined over ASCII, so raw bytes must be classified without regard to the locale. Add detail::is_ascii_digit/is_ascii_alpha/is_ascii_alnum and use them at every classification site: multipart boundary validation, token checks, URI encoding, range header parsing, is_numeric, is_hex, and IPv4 host detection. Also unify the hand-written digit range checks in from_chars, URL parsing, and parse_ipv4 onto the same helpers. With these in place nothing uses anymore, so the include is dropped. --- httplib.h | 67 ++++++++++++++++++++++++++++++---------------------- test/test.cc | 25 ++++++++++++++++++++ 2 files changed, 64 insertions(+), 28 deletions(-) diff --git a/httplib.h b/httplib.h index 439d915..5bdb809 100644 --- a/httplib.h +++ b/httplib.h @@ -309,7 +309,6 @@ using socket_t = int; #include #include #include -#include #include #include #include @@ -540,6 +539,21 @@ make_unique(std::size_t n) { return std::unique_ptr(new RT[n]); } +// Locale-independent ASCII character classification. The +// counterparts (std::isalnum, std::isdigit, ...) consult the global C locale, +// so e.g. std::isalnum(0xC5) can return true once an embedder calls +// setlocale(). HTTP grammars are defined over ASCII, so raw bytes must be +// classified without regard to the locale. +inline bool is_ascii_digit(char c) { return '0' <= c && c <= '9'; } + +inline bool is_ascii_alpha(char c) { + return ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z'); +} + +inline bool is_ascii_alnum(char c) { + return is_ascii_digit(c) || is_ascii_alpha(c); +} + namespace case_ignore { inline unsigned char to_lower(int c) { @@ -661,7 +675,7 @@ inline from_chars_result from_chars(const char *first, const char *last, for (; p != last; ++p) { char c = *p; int digit = -1; - if ('0' <= c && c <= '9') { + if (is_ascii_digit(c)) { digit = c - '0'; } else if ('a' <= c && c <= 'z') { digit = c - 'a' + 10; @@ -733,14 +747,14 @@ inline from_chars_result from_chars(const char *first, const char *last, return false; }; - for (; p != last && '0' <= *p && *p <= '9'; ++p) { + for (; p != last && is_ascii_digit(*p); ++p) { seen_digit = true; accumulate(*p); } if (p != last && *p == '.') { ++p; - for (; p != last && '0' <= *p && *p <= '9'; ++p) { + for (; p != last && is_ascii_digit(*p); ++p) { seen_digit = true; if (frac_digits < max_frac_digits && accumulate(*p)) { ++frac_digits; } } @@ -803,8 +817,8 @@ inline bool parse_url(const std::string &url, UrlComponents &uc) { // IPv6 host must be [a-fA-F0-9:]+ only if (uc.host.empty()) { return false; } for (auto c : uc.host) { - if (!((c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') || - (c >= '0' && c <= '9') || c == ':')) { + if (!(is_ascii_digit(c) || (c >= 'a' && c <= 'f') || + (c >= 'A' && c <= 'F') || c == ':')) { return false; } } @@ -2929,9 +2943,7 @@ template inline constexpr size_t str_len(const char (&)[N]) { } inline bool is_numeric(const std::string &str) { - return !str.empty() && - std::all_of(str.cbegin(), str.cend(), - [](unsigned char c) { return std::isdigit(c); }); + return !str.empty() && std::all_of(str.cbegin(), str.cend(), is_ascii_digit); } inline size_t get_header_value_u64(const Headers &headers, @@ -4480,7 +4492,7 @@ inline bool set_socket_opt_time(socket_t sock, int level, int optname, } inline bool is_hex(char c, int &v) { - if (isdigit(static_cast(c))) { + if (is_ascii_digit(c)) { v = c - '0'; return true; } else if ('A' <= c && c <= 'F') { @@ -7893,8 +7905,7 @@ inline bool parse_range_header(const std::string &s, Ranges &ranges) { inline bool parse_range_header(const std::string &s, Ranges &ranges) try { #endif auto is_valid = [](const std::string &str) { - return std::all_of(str.cbegin(), str.cend(), - [](unsigned char c) { return std::isdigit(c); }); + return std::all_of(str.cbegin(), str.cend(), is_ascii_digit); }; if (s.size() > 7 && s.compare(0, 6, "bytes=") == 0) { @@ -8342,7 +8353,7 @@ inline bool is_multipart_boundary_chars_valid(const std::string &boundary) { auto valid = true; for (size_t i = 0; i < boundary.size(); i++) { auto c = boundary[i]; - if (!std::isalnum(static_cast(c)) && c != '-' && c != '_') { + if (!is_ascii_alnum(c) && c != '-' && c != '_') { valid = false; break; } @@ -8856,10 +8867,9 @@ private: namespace fields { inline bool is_token_char(char c) { - return std::isalnum(static_cast(c)) || c == '!' || c == '#' || - c == '$' || c == '%' || c == '&' || c == '\'' || c == '*' || - c == '+' || c == '-' || c == '.' || c == '^' || c == '_' || c == '`' || - c == '|' || c == '~'; + return is_ascii_alnum(c) || c == '!' || c == '#' || c == '$' || c == '%' || + c == '&' || c == '\'' || c == '*' || c == '+' || c == '-' || + c == '.' || c == '^' || c == '_' || c == '`' || c == '|' || c == '~'; } inline bool is_token(const std::string &s) { @@ -9635,9 +9645,8 @@ inline std::string encode_uri_component(const std::string &value) { escaped << std::hex; for (auto c : value) { - if (std::isalnum(static_cast(c)) || c == '-' || c == '_' || - c == '.' || c == '!' || c == '~' || c == '*' || c == '\'' || c == '(' || - c == ')') { + if (detail::is_ascii_alnum(c) || c == '-' || c == '_' || c == '.' || + c == '!' || c == '~' || c == '*' || c == '\'' || c == '(' || c == ')') { escaped << c; } else { escaped << std::uppercase; @@ -9656,10 +9665,10 @@ inline std::string encode_uri(const std::string &value) { escaped << std::hex; for (auto c : value) { - if (std::isalnum(static_cast(c)) || c == '-' || c == '_' || - c == '.' || c == '!' || c == '~' || c == '*' || c == '\'' || c == '(' || - c == ')' || c == ';' || c == '/' || c == '?' || c == ':' || c == '@' || - c == '&' || c == '=' || c == '+' || c == '$' || c == ',' || c == '#') { + if (detail::is_ascii_alnum(c) || c == '-' || c == '_' || c == '.' || + c == '!' || c == '~' || c == '*' || c == '\'' || c == '(' || c == ')' || + c == ';' || c == '/' || c == '?' || c == ':' || c == '@' || c == '&' || + c == '=' || c == '+' || c == '$' || c == ',' || c == '#') { escaped << c; } else { escaped << std::uppercase; @@ -9720,7 +9729,8 @@ inline std::string encode_path_component(const std::string &component) { auto c = static_cast(component[i]); // Unreserved characters per RFC 3986: ALPHA / DIGIT / "-" / "." / "_" / "~" - if (std::isalnum(c) || c == '-' || c == '.' || c == '_' || c == '~') { + if (detail::is_ascii_alnum(static_cast(c)) || c == '-' || c == '.' || + c == '_' || c == '~') { result += static_cast(c); } // Path-safe sub-delimiters: "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / @@ -9793,7 +9803,8 @@ inline std::string encode_query_component(const std::string &component, auto c = static_cast(component[i]); // Unreserved characters per RFC 3986 - if (std::isalnum(c) || c == '-' || c == '.' || c == '_' || c == '~') { + if (detail::is_ascii_alnum(static_cast(c)) || c == '-' || c == '.' || + c == '_' || c == '~') { result += static_cast(c); } // Space handling @@ -16607,7 +16618,7 @@ inline bool is_ipv4_address(const std::string &str) { for (char c : str) { if (c == '.') { dots++; - } else if (!isdigit(static_cast(c))) { + } else if (!detail::is_ascii_digit(c)) { return false; } } @@ -16624,7 +16635,7 @@ inline bool parse_ipv4(const std::string &str, unsigned char *out) { } int val = 0; int digits = 0; - while (*p >= '0' && *p <= '9') { + while (detail::is_ascii_digit(*p)) { val = val * 10 + (*p - '0'); if (val > 255) { return false; } p++; diff --git a/test/test.cc b/test/test.cc index 9fd015a..2c88378 100644 --- a/test/test.cc +++ b/test/test.cc @@ -666,6 +666,31 @@ TEST(TrimTests, TrimStringTests) { EXPECT_TRUE(detail::trim_copy("").empty()); } +TEST(AsciiTest, LocaleIndependentClassification) { + // detail::is_ascii_digit/alpha/alnum replace the classifiers, + // which consult the global C locale: std::isalnum(0xC5) can return true + // once an embedder calls setlocale() (observed on macOS). The is_ascii_* + // helpers must classify every byte by the ASCII grammar alone. + for (int i = 0; i < 256; i++) { + auto c = static_cast(i); + auto is_digit = i >= '0' && i <= '9'; + auto is_upper = i >= 'A' && i <= 'Z'; + auto is_lower = i >= 'a' && i <= 'z'; + + EXPECT_EQ(is_digit, detail::is_ascii_digit(c)) << "byte " << i; + EXPECT_EQ(is_upper || is_lower, detail::is_ascii_alpha(c)) << "byte " << i; + EXPECT_EQ(is_digit || is_upper || is_lower, detail::is_ascii_alnum(c)) + << "byte " << i; + } + + // Regression check for the reported byte: 0xC5 is not alphanumeric. + EXPECT_FALSE(detail::is_ascii_alnum(static_cast(0xC5))); + + // Non-ASCII bytes must be percent-encoded, never passed through as + // alphanumeric. + EXPECT_EQ("%C5", httplib::encode_uri_component("\xC5")); +} + 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 ]"