Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions src/odr/internal/util/byte_string.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<char>(value >> 8);
Expand All @@ -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<char>(value >> 24);
Expand Down
22 changes: 16 additions & 6 deletions src/odr/internal/util/string_util.cpp
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
#include <odr/internal/util/string_util.hpp>

#include <algorithm>
#include <cctype>
#include <cstdint>
#include <iterator>
#include <stdexcept>

#include <fmt/format.h>

#include <utf8/unchecked.h>
#include <utf8cpp/utf8/checked.h>
#include <utf8cpp/utf8/cpp17.h>

Expand All @@ -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<std::uint8_t>(c)) != 0;
return c == ' ' || (c >= '\t' && c <= '\r');
}

bool string::is_ascii_digit(const char c) { return c >= '0' && c <= '9'; }
Expand All @@ -37,11 +35,11 @@ bool string::is_ascii_letter_or_digit(const char c) {
}

char string::to_lower(const char c) {
return static_cast<char>(std::tolower(static_cast<std::uint8_t>(c)));
return c >= 'A' && c <= 'Z' ? static_cast<char>(c + ('a' - 'A')) : c;
}

char string::to_upper(const char c) {
return static_cast<char>(std::toupper(static_cast<std::uint8_t>(c)));
return c >= 'a' && c <= 'z' ? static_cast<char>(c - ('a' - 'A')) : c;
}

std::string string::to_lower(const std::string_view string) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -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<std::size_t>
Expand Down
1 change: 1 addition & 0 deletions src/odr/internal/util/string_util.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
16 changes: 16 additions & 0 deletions test/src/internal/util/stream_util_test.cpp
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#include <odr/internal/util/byte_stream_util.hpp>
#include <odr/internal/util/byte_string.hpp>
#include <odr/internal/util/stream_util.hpp>

#include <ios>
Expand All @@ -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<std::size_t>::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) {
Expand Down
42 changes: 42 additions & 0 deletions test/src/internal/util/string_util_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@

#include <gtest/gtest.h>

#include <clocale>
#include <cstddef>
#include <cstdint>
#include <limits>
#include <stdexcept>
#include <string_view>
#include <tuple>
#include <vector>
Expand Down Expand Up @@ -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<char>(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("", ""));
Expand All @@ -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);

Expand All @@ -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<std::size_t>::max()), "");
EXPECT_THROW(repeat("ab", std::numeric_limits<std::size_t>::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
Expand Down
Loading