diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a1358492..9a2d04096 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ The release run heads these entries with the version and opens a fresh ## Unreleased +- Shared text helpers keep ASCII matching independent of locale and reject + malformed UTF-8, empty replacement patterns and overflowing repetitions. + In-memory byte writes reject offsets that overflow their bounds checks. + - Release automation preserves literal asset paths and rejects malformed version overrides before changing package metadata. diff --git a/src/odr/internal/util/byte_string.cpp b/src/odr/internal/util/byte_string.cpp index eae3c622e..3038505e4 100644 --- a/src/odr/internal/util/byte_string.cpp +++ b/src/odr/internal/util/byte_string.cpp @@ -92,7 +92,7 @@ void byte_string::put_u32_be(std::string &out, const std::uint32_t value) { void byte_string::write_u16_be(std::string &out, const std::size_t pos, const std::uint16_t value) { - if (pos + 2 > out.size()) { + if (pos > out.size() || out.size() - pos < 2) { throw std::runtime_error("byte_string: write past end"); } out[pos] = static_cast(value >> 8); @@ -101,7 +101,7 @@ void byte_string::write_u16_be(std::string &out, const std::size_t pos, void byte_string::write_u32_be(std::string &out, const std::size_t pos, const std::uint32_t value) { - if (pos + 4 > out.size()) { + if (pos > out.size() || out.size() - pos < 4) { throw std::runtime_error("byte_string: write past end"); } out[pos] = static_cast(value >> 24); diff --git a/src/odr/internal/util/string_util.cpp b/src/odr/internal/util/string_util.cpp index 963422622..23b1dc614 100644 --- a/src/odr/internal/util/string_util.cpp +++ b/src/odr/internal/util/string_util.cpp @@ -1,14 +1,12 @@ #include #include -#include #include #include #include #include -#include #include #include @@ -23,7 +21,7 @@ bool string::ends_with(const std::string &string, const std::string &with) { } bool string::is_ascii_whitespace(const char c) { - return std::isspace(static_cast(c)) != 0; + return c == ' ' || (c >= '\t' && c <= '\r'); } bool string::is_ascii_digit(const char c) { return c >= '0' && c <= '9'; } @@ -37,11 +35,11 @@ bool string::is_ascii_letter_or_digit(const char c) { } char string::to_lower(const char c) { - return static_cast(std::tolower(static_cast(c))); + return c >= 'A' && c <= 'Z' ? static_cast(c + ('a' - 'A')) : c; } char string::to_upper(const char c) { - return static_cast(std::toupper(static_cast(c))); + return c >= 'a' && c <= 'z' ? static_cast(c - ('a' - 'A')) : c; } std::string string::to_lower(const std::string_view string) { @@ -71,6 +69,9 @@ std::size_t string::find_ignore_case(const std::string_view string, if (from > string.size()) { return std::string_view::npos; } + if (needle.empty()) { + return from; + } const std::string_view rest = string.substr(from); const auto found = std::ranges::search(rest, needle, [](const char x, const char y) { @@ -141,6 +142,9 @@ std::string_view string::trim_view(std::string_view s, void string::replace_all(std::string &string, const std::string &search, const std::string &replace) { + if (search.empty()) { + throw std::invalid_argument("search must not be empty"); + } std::size_t pos = string.find(search); while (pos != std::string::npos) { string.replace(pos, search.size(), replace); @@ -150,6 +154,12 @@ void string::replace_all(std::string &string, const std::string &search, std::string string::repeat(const std::string &unit, const std::size_t count) { std::string result; + if (unit.empty()) { + return result; + } + if (count > result.max_size() / unit.size()) { + throw std::length_error("repeated string is too large"); + } result.reserve(unit.size() * count); for (std::size_t i = 0; i < count; ++i) { result += unit; @@ -189,7 +199,7 @@ std::string string::to_string(const double d, const int precision) { } std::size_t string::utf8_length(const std::string &string) { - return utf8::unchecked::distance(string.begin(), string.end()); + return utf8::distance(string.begin(), string.end()); } std::vector diff --git a/src/odr/internal/util/string_util.hpp b/src/odr/internal/util/string_util.hpp index 3f862a4fe..135a9ef16 100644 --- a/src/odr/internal/util/string_util.hpp +++ b/src/odr/internal/util/string_util.hpp @@ -62,6 +62,7 @@ std::string_view rtrim_view(std::string_view s, std::string_view trim_view(std::string_view s, CharPredicate is_whitespace = is_ascii_whitespace); +/// Replaces each occurrence; an empty @p search throws `std::invalid_argument`. void replace_all(std::string &string, const std::string &search, const std::string &replace); diff --git a/test/src/internal/util/stream_util_test.cpp b/test/src/internal/util/stream_util_test.cpp index 0994049c2..3a66830a4 100644 --- a/test/src/internal/util/stream_util_test.cpp +++ b/test/src/internal/util/stream_util_test.cpp @@ -1,4 +1,5 @@ #include +#include #include #include @@ -11,6 +12,21 @@ using namespace odr::internal::util; +TEST(ByteString, patch_offsets_are_checked_before_addition) { + std::string bytes(4, '\0'); + byte_string::write_u32_be(bytes, 0, 0x12345678); + EXPECT_EQ(bytes, "\x12\x34\x56\x78"); + byte_string::write_u16_be(bytes, 2, 0xabcd); + EXPECT_EQ(bytes, "\x12\x34\xab\xcd"); + for (const std::size_t offset : + {std::size_t{4}, std::numeric_limits::max()}) { + EXPECT_THROW(byte_string::write_u16_be(bytes, offset, 0), + std::runtime_error); + EXPECT_THROW(byte_string::write_u32_be(bytes, offset, 0), + std::runtime_error); + } +} + // A `ViewStream` is seekable: pdf object streams address their members by // absolute position rather than reading them in order. TEST(ViewStream, seek) { diff --git a/test/src/internal/util/string_util_test.cpp b/test/src/internal/util/string_util_test.cpp index 383b9a9fb..2755298af 100644 --- a/test/src/internal/util/string_util_test.cpp +++ b/test/src/internal/util/string_util_test.cpp @@ -2,8 +2,11 @@ #include +#include #include #include +#include +#include #include #include #include @@ -148,6 +151,23 @@ TEST(string_util, to_lower) { EXPECT_EQ(to_lower("ÄÖÜ"), "ÄÖÜ"); } +TEST(string_util, ascii_classes_ignore_the_host_locale) { + const std::string previous = std::setlocale(LC_CTYPE, nullptr); + if (std::setlocale(LC_CTYPE, "tr_TR.ISO8859-9") == nullptr && + std::setlocale(LC_CTYPE, "tr_TR.UTF-8") == nullptr) { + GTEST_SKIP() << "Turkish locale unavailable"; + } + EXPECT_EQ(to_lower('I'), 'i'); + EXPECT_EQ(to_upper('i'), 'I'); + for (std::uint32_t byte = 128; byte <= 255; ++byte) { + const char c = static_cast(byte); + EXPECT_EQ(to_lower(c), c); + EXPECT_EQ(to_upper(c), c); + EXPECT_FALSE(is_ascii_whitespace(c)); + } + std::setlocale(LC_CTYPE, previous.c_str()); +} + TEST(string_util, equals_ignore_case) { EXPECT_TRUE(equals_ignore_case("script", "SCRIPT")); EXPECT_TRUE(equals_ignore_case("", "")); @@ -167,6 +187,9 @@ TEST(string_util, starts_with_ignore_case) { } TEST(string_util, find_ignore_case) { + EXPECT_EQ(find_ignore_case("abc", "", 2), 2); + EXPECT_EQ(find_ignore_case("", ""), 0); + EXPECT_EQ(find_ignore_case("abc", "", 4), std::string_view::npos); EXPECT_EQ(find_ignore_case("a @IMPORT b", "@import"), 2); EXPECT_EQ(find_ignore_case("abc", "d"), std::string_view::npos); @@ -179,6 +202,25 @@ TEST(string_util, find_ignore_case) { EXPECT_EQ(find_ignore_case("abc", "a", 99), std::string_view::npos); } +TEST(string_util, replacement_and_repetition_boundaries) { + std::string text = "abcabc"; + replace_all(text, "ab", "x"); + EXPECT_EQ(text, "xcxc"); + EXPECT_THROW(replace_all(text, "", "x"), std::invalid_argument); + EXPECT_EQ(text, "xcxc"); + EXPECT_EQ(repeat("ab", 3), "ababab"); + EXPECT_EQ(repeat("ab", 0), ""); + EXPECT_EQ(repeat("", std::numeric_limits::max()), ""); + EXPECT_THROW(repeat("ab", std::numeric_limits::max()), + std::length_error); +} + +TEST(string_util, utf8_length_checks_incomplete_sequences) { + EXPECT_EQ(utf8_length("a\xf0\x9f\x98\x80z"), 3); + EXPECT_ANY_THROW(utf8_length("\xf0\x9f")); + EXPECT_ANY_THROW(utf8_length("\x80")); +} + namespace { /// `utf16_offsets` over a braced index list, which its `std::span` does not