Skip to content

Commit cf0af5b

Browse files
goel-skdclaude
andauthored
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 <noreply@anthropic.com>
1 parent a2bbf18 commit cf0af5b

2 files changed

Lines changed: 48 additions & 3 deletions

File tree

src/iceberg/test/string_util_test.cc

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,32 @@ TEST(StringUtilsTest, ToUpper) {
4141
ASSERT_EQ(StringUtils::ToUpper("123"), "123");
4242
}
4343

44+
// Non-ASCII (multibyte UTF-8) bytes have the high bit set, i.e. are negative when stored
45+
// in a signed char. Only ASCII letters are converted; multibyte bytes pass through
46+
// unchanged. The non-ASCII strings are written as explicit UTF-8 byte escapes so the test
47+
// does not depend on the source-file encoding. See
48+
// https://github.com/apache/iceberg-cpp/issues/613.
49+
TEST(StringUtilsTest, NonAsciiPassThrough) {
50+
// "Naïve" -> "naïve" (ï = U+00EF = 0xC3 0xAF; only the ASCII letters change).
51+
ASSERT_EQ(StringUtils::ToLower("Na\xC3\xAFve"), "na\xC3\xAFve");
52+
// "café" -> "CAFé" (é = U+00E9 = 0xC3 0xA9 stays unchanged).
53+
ASSERT_EQ(StringUtils::ToUpper("caf\xC3\xA9"), "CAF\xC3\xA9");
54+
// "日本語" (0xE6 0x97 0xA5 0xE6 0x9C 0xAC 0xE8 0xAA 0x9E) is returned verbatim.
55+
ASSERT_EQ(StringUtils::ToLower("\xE6\x97\xA5\xE6\x9C\xAC\xE8\xAA\x9E"),
56+
"\xE6\x97\xA5\xE6\x9C\xAC\xE8\xAA\x9E");
57+
ASSERT_EQ(StringUtils::ToUpper("\xE6\x97\xA5\xE6\x9C\xAC\xE8\xAA\x9E"),
58+
"\xE6\x97\xA5\xE6\x9C\xAC\xE8\xAA\x9E");
59+
}
60+
61+
TEST(StringUtilsTest, EqualsIgnoreCase) {
62+
ASSERT_TRUE(StringUtils::EqualsIgnoreCase("AbC", "abc"));
63+
ASSERT_TRUE(StringUtils::EqualsIgnoreCase("", ""));
64+
ASSERT_FALSE(StringUtils::EqualsIgnoreCase("abc", "abcd"));
65+
ASSERT_FALSE(StringUtils::EqualsIgnoreCase("abc", "abd"));
66+
// ASCII case is folded; non-ASCII bytes are compared as-is. ("Café" vs "café")
67+
ASSERT_TRUE(StringUtils::EqualsIgnoreCase("Caf\xC3\xA9", "caf\xC3\xA9"));
68+
// "café" vs "cafe": the multibyte é differs from ASCII 'e'.
69+
ASSERT_FALSE(StringUtils::EqualsIgnoreCase("caf\xC3\xA9", "cafe"));
70+
}
71+
4472
} // namespace iceberg

src/iceberg/util/string_util.h

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
#pragma once
2121

2222
#include <algorithm>
23+
#include <cctype>
2324
#include <cerrno>
2425
#include <charconv>
2526
#include <ranges>
@@ -40,19 +41,22 @@ concept FromChars = requires(const char* p, T& v) { std::from_chars(p, p, v); };
4041

4142
class ICEBERG_EXPORT StringUtils {
4243
public:
44+
// NOTE: These convert ASCII letters only; all other bytes, including non-ASCII
45+
// (multibyte UTF-8) bytes, are passed through unchanged.
46+
// See https://github.com/apache/iceberg-cpp/issues/613.
4347
static std::string ToLower(std::string_view str) {
44-
return str | std::ranges::views::transform([](char c) { return std::tolower(c); }) |
48+
return str | std::ranges::views::transform(ToLowerAscii) |
4549
std::ranges::to<std::string>();
4650
}
4751

4852
static std::string ToUpper(std::string_view str) {
49-
return str | std::ranges::views::transform([](char c) { return std::toupper(c); }) |
53+
return str | std::ranges::views::transform(ToUpperAscii) |
5054
std::ranges::to<std::string>();
5155
}
5256

5357
static bool EqualsIgnoreCase(std::string_view lhs, std::string_view rhs) {
5458
return std::ranges::equal(
55-
lhs, rhs, [](char lc, char rc) { return std::tolower(lc) == std::tolower(rc); });
59+
lhs, rhs, [](char lc, char rc) { return ToLowerAscii(lc) == ToLowerAscii(rc); });
5660
}
5761

5862
static bool StartsWithIgnoreCase(std::string_view str, std::string_view prefix) {
@@ -128,6 +132,19 @@ class ICEBERG_EXPORT StringUtils {
128132
}
129133
return value;
130134
}
135+
136+
private:
137+
// ASCII-only case conversion using explicit range checks rather than
138+
// std::tolower/std::toupper. This is independent of the current C locale and never
139+
// touches non-ASCII (high-bit) bytes, so multibyte UTF-8 sequences are preserved. It
140+
// also sidesteps the undefined behavior of passing a negative char to <cctype>.
141+
static constexpr char ToLowerAscii(char c) noexcept {
142+
return (c >= 'A' && c <= 'Z') ? static_cast<char>(c - 'A' + 'a') : c;
143+
}
144+
145+
static constexpr char ToUpperAscii(char c) noexcept {
146+
return (c >= 'a' && c <= 'z') ? static_cast<char>(c - 'a' + 'A') : c;
147+
}
131148
};
132149

133150
/// \brief Transparent hash function that supports std::string_view as lookup key

0 commit comments

Comments
 (0)