You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@iceberg.apache.org by ga...@apache.org on 2026/06/17 05:27:50 UTC

(iceberg-cpp) branch main updated: fix: cast to unsigned char in ASCII case conversion (#748)

This is an automated email from the ASF dual-hosted git repository.

wgtmac pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg-cpp.git


The following commit(s) were added to refs/heads/main by this push:
     new cf0af5b1 fix: cast to unsigned char in ASCII case conversion (#748)
cf0af5b1 is described below

commit cf0af5b1b8aaf0df971f1c0ee07e83e68b0f6567
Author: Rahul Goel <go...@gmail.com>
AuthorDate: Wed Jun 17 01:27:46 2026 -0400

    fix: cast to unsigned char in ASCII case conversion (#748)
    
    fix: cast to unsigned char in ASCII case conversion
    
    `std::tolower`/`toupper` are UB when passed a negative `char`, which is
    what you get for any non-ASCII byte in a signed `char`.
    `StringUtils::ToLower`, `ToUpper`, and `EqualsIgnoreCase`
      all did this. Cast through `unsigned char`.
    
    Behavior for ASCII is unchanged; this doesn't add Unicode case folding,
    just fixes the UB.
    
      Related: #613
    
    ---------
    
    Co-authored-by: Claude Opus 4.8 <no...@anthropic.com>
---
 src/iceberg/test/string_util_test.cc | 28 ++++++++++++++++++++++++++++
 src/iceberg/util/string_util.h       | 23 ++++++++++++++++++++---
 2 files changed, 48 insertions(+), 3 deletions(-)

diff --git a/src/iceberg/test/string_util_test.cc b/src/iceberg/test/string_util_test.cc
index 40258811..a3fd0376 100644
--- a/src/iceberg/test/string_util_test.cc
+++ b/src/iceberg/test/string_util_test.cc
@@ -41,4 +41,32 @@ TEST(StringUtilsTest, ToUpper) {
   ASSERT_EQ(StringUtils::ToUpper("123"), "123");
 }
 
+// Non-ASCII (multibyte UTF-8) bytes have the high bit set, i.e. are negative when stored
+// in a signed char. Only ASCII letters are converted; multibyte bytes pass through
+// unchanged. The non-ASCII strings are written as explicit UTF-8 byte escapes so the test
+// does not depend on the source-file encoding. See
+// https://github.com/apache/iceberg-cpp/issues/613.
+TEST(StringUtilsTest, NonAsciiPassThrough) {
+  // "Naïve" -> "naïve" (ï = U+00EF = 0xC3 0xAF; only the ASCII letters change).
+  ASSERT_EQ(StringUtils::ToLower("Na\xC3\xAFve"), "na\xC3\xAFve");
+  // "café" -> "CAFé" (é = U+00E9 = 0xC3 0xA9 stays unchanged).
+  ASSERT_EQ(StringUtils::ToUpper("caf\xC3\xA9"), "CAF\xC3\xA9");
+  // "日本語" (0xE6 0x97 0xA5 0xE6 0x9C 0xAC 0xE8 0xAA 0x9E) is returned verbatim.
+  ASSERT_EQ(StringUtils::ToLower("\xE6\x97\xA5\xE6\x9C\xAC\xE8\xAA\x9E"),
+            "\xE6\x97\xA5\xE6\x9C\xAC\xE8\xAA\x9E");
+  ASSERT_EQ(StringUtils::ToUpper("\xE6\x97\xA5\xE6\x9C\xAC\xE8\xAA\x9E"),
+            "\xE6\x97\xA5\xE6\x9C\xAC\xE8\xAA\x9E");
+}
+
+TEST(StringUtilsTest, EqualsIgnoreCase) {
+  ASSERT_TRUE(StringUtils::EqualsIgnoreCase("AbC", "abc"));
+  ASSERT_TRUE(StringUtils::EqualsIgnoreCase("", ""));
+  ASSERT_FALSE(StringUtils::EqualsIgnoreCase("abc", "abcd"));
+  ASSERT_FALSE(StringUtils::EqualsIgnoreCase("abc", "abd"));
+  // ASCII case is folded; non-ASCII bytes are compared as-is. ("Café" vs "café")
+  ASSERT_TRUE(StringUtils::EqualsIgnoreCase("Caf\xC3\xA9", "caf\xC3\xA9"));
+  // "café" vs "cafe": the multibyte é differs from ASCII 'e'.
+  ASSERT_FALSE(StringUtils::EqualsIgnoreCase("caf\xC3\xA9", "cafe"));
+}
+
 }  // namespace iceberg
diff --git a/src/iceberg/util/string_util.h b/src/iceberg/util/string_util.h
index 36dfba30..01b6087b 100644
--- a/src/iceberg/util/string_util.h
+++ b/src/iceberg/util/string_util.h
@@ -20,6 +20,7 @@
 #pragma once
 
 #include <algorithm>
+#include <cctype>
 #include <cerrno>
 #include <charconv>
 #include <ranges>
@@ -40,19 +41,22 @@ concept FromChars = requires(const char* p, T& v) { std::from_chars(p, p, v); };
 
 class ICEBERG_EXPORT StringUtils {
  public:
+  // NOTE: These convert ASCII letters only; all other bytes, including non-ASCII
+  // (multibyte UTF-8) bytes, are passed through unchanged.
+  // See https://github.com/apache/iceberg-cpp/issues/613.
   static std::string ToLower(std::string_view str) {
-    return str | std::ranges::views::transform([](char c) { return std::tolower(c); }) |
+    return str | std::ranges::views::transform(ToLowerAscii) |
            std::ranges::to<std::string>();
   }
 
   static std::string ToUpper(std::string_view str) {
-    return str | std::ranges::views::transform([](char c) { return std::toupper(c); }) |
+    return str | std::ranges::views::transform(ToUpperAscii) |
            std::ranges::to<std::string>();
   }
 
   static bool EqualsIgnoreCase(std::string_view lhs, std::string_view rhs) {
     return std::ranges::equal(
-        lhs, rhs, [](char lc, char rc) { return std::tolower(lc) == std::tolower(rc); });
+        lhs, rhs, [](char lc, char rc) { return ToLowerAscii(lc) == ToLowerAscii(rc); });
   }
 
   static bool StartsWithIgnoreCase(std::string_view str, std::string_view prefix) {
@@ -128,6 +132,19 @@ class ICEBERG_EXPORT StringUtils {
     }
     return value;
   }
+
+ private:
+  // ASCII-only case conversion using explicit range checks rather than
+  // std::tolower/std::toupper. This is independent of the current C locale and never
+  // touches non-ASCII (high-bit) bytes, so multibyte UTF-8 sequences are preserved. It
+  // also sidesteps the undefined behavior of passing a negative char to <cctype>.
+  static constexpr char ToLowerAscii(char c) noexcept {
+    return (c >= 'A' && c <= 'Z') ? static_cast<char>(c - 'A' + 'a') : c;
+  }
+
+  static constexpr char ToUpperAscii(char c) noexcept {
+    return (c >= 'a' && c <= 'z') ? static_cast<char>(c - 'a' + 'A') : c;
+  }
 };
 
 /// \brief Transparent hash function that supports std::string_view as lookup key