Adds URL escaping utilities The following functions are added: absl::UrlEscape() absl::UrlUnescape() absl::UrlEscapePlus() absl::UrlUnescapePlus() #158 PiperOrigin-RevId: 943451273 Change-Id: I92c22005d6a75265b5313a7a99ce2f855e19ede1
diff --git a/absl/strings/BUILD.bazel b/absl/strings/BUILD.bazel index 5d4319d..7990977 100644 --- a/absl/strings/BUILD.bazel +++ b/absl/strings/BUILD.bazel
@@ -233,7 +233,9 @@ tags = ["no_test_chromiumos_x86_64"], visibility = ["//visibility:private"], deps = [ + ":charset", ":cord", + ":str_format", ":strings", "//absl/base:core_headers", "//absl/container:fixed_array",
diff --git a/absl/strings/CMakeLists.txt b/absl/strings/CMakeLists.txt index 964094f..c93d654 100644 --- a/absl/strings/CMakeLists.txt +++ b/absl/strings/CMakeLists.txt
@@ -246,7 +246,9 @@ COPTS ${ABSL_TEST_COPTS} DEPS + absl::charset absl::strings + absl::str_format absl::core_headers absl::fixed_array GTest::gmock_main
diff --git a/absl/strings/escaping.cc b/absl/strings/escaping.cc index ea5a958..e9119ca 100644 --- a/absl/strings/escaping.cc +++ b/absl/strings/escaping.cc
@@ -20,7 +20,9 @@ #include <cstddef> #include <cstdint> #include <cstring> +#include <iterator> #include <limits> +#include <optional> #include <string> #include <utility> @@ -61,6 +63,12 @@ return x & 0xf; } +inline char int_to_hex_digit(int i) { + assert(i >= 0 && i <= 15); + return ((i < 10) ? (static_cast<char>(i) + '0') + : (static_cast<char>(i - 10) + 'A')); +} + inline bool IsSurrogate(char32_t c, absl::string_view src, std::string* absl_nullable error) { if (c >= 0xD800 && c <= 0xDFFF) { @@ -1179,5 +1187,127 @@ return result; } +static std::string UrlEscapeInternal(absl::string_view input, + const bool escape_space_to_plus) { + // Unreserved characters from RFC 3986. + // See https://www.rfc-editor.org/info/rfc3986/#section-2.3. + static constexpr absl::CharSet kRfc3986Unreserved = + absl::CharSet::AsciiAlphanumerics() | absl::CharSet("-._~"); + + std::string output; + absl::string_view::iterator in = input.begin(); + + // Fast path for when we don't need to do any escaping. + while (in < input.end() && kRfc3986Unreserved.contains(*in)) { + ++in; + } + + std::size_t initial_portion = + static_cast<std::size_t>(std::distance(input.begin(), in)); + + if (initial_portion == input.size()) { + return std::string(input); + } + + // We need a buffer with enough space to store at most the initial portion + // plus 3 bytes for each remaining character since escapes use 3 characters. + StringResizeAndOverwrite( + output, initial_portion + 3 * (input.size() - initial_portion), + [&](char* buf, size_t) { + char* out = buf; + + // Copy the initial portion that did not need escaping. + out = std::copy(input.begin(), in, out); + + // Handle the rest of the string. + while (in < input.end()) { + char c = *in++; + if (kRfc3986Unreserved.contains(c)) { + *out++ = c; + } else if (escape_space_to_plus && c == ' ') { + *out++ = '+'; + } else { + *out++ = '%'; + *out++ = static_cast<char>( + int_to_hex_digit((static_cast<unsigned char>(c) >> 4) & 0xf)); + *out++ = static_cast<char>( + int_to_hex_digit(static_cast<unsigned char>(c) & 0xf)); + } + } + return static_cast<size_t>(std::distance(buf, out)); + }); + + return output; +} + +static std::optional<std::string> UrlUnescapeInternal( + absl::string_view input, const bool unescape_plus_to_space) { + std::string output; + + // Fast path for when we don't need to do any unescaping. + // This case includes empty input, which allows us to return 0 from the + // lambda below to signal the error case. + size_t in = + unescape_plus_to_space ? input.find_first_of("%+") : input.find('%'); + if (in == input.npos) { + return std::string(input); + } + + StringResizeAndOverwrite(output, input.size(), [&](char* buf, size_t) { + char* out = buf; + + // Copy the initial portion that did not need unescaping. + out = std::copy_n(input.data(), in, out); + + // Handle the rest of the string. + while (in < input.size()) { + char c = input[in++]; + if (unescape_plus_to_space && c == '+') { + *out++ = ' '; + } else if (c == '%') { + if (in + 1 >= input.size() || + !absl::ascii_isxdigit(static_cast<unsigned char>(input[in])) || + !absl::ascii_isxdigit(static_cast<unsigned char>(input[in + 1]))) { + return size_t{0}; // Error. + } + int x = static_cast<int>(hex_digit_to_int(input[in++])) << 4; + x += static_cast<int>(hex_digit_to_int(input[in++])); + *out++ = static_cast<char>(x); + } else { + *out++ = c; + } + } + return static_cast<size_t>(std::distance(buf, out)); + }); + + if (output.empty()) { + // Empty output is only valid if the input was empty, and that case is + // handled above. + return std::nullopt; + } + + return output; +} + +std::string UrlEscape(absl::string_view input) { + constexpr bool kEscapeSpaceToPlus = false; + return UrlEscapeInternal(input, kEscapeSpaceToPlus); +} + +std::optional<std::string> UrlUnescape(absl::string_view input) { + constexpr bool kUnescapePlusToSpace = false; + return UrlUnescapeInternal(input, kUnescapePlusToSpace); +} + +std::string UrlEscapePlus(absl::string_view input) { + constexpr bool kEscapeSpaceToPlus = true; + return UrlEscapeInternal(input, kEscapeSpaceToPlus); +} + +std::optional<std::string> UrlUnescapePlus(absl::string_view input) { + constexpr bool kUnescapePlusToSpace = true; + return UrlUnescapeInternal(input, kUnescapePlusToSpace); +} + ABSL_NAMESPACE_END } // namespace absl
diff --git a/absl/strings/escaping.h b/absl/strings/escaping.h index 4a23c13..3dbaa5b 100644 --- a/absl/strings/escaping.h +++ b/absl/strings/escaping.h
@@ -24,6 +24,7 @@ #define ABSL_STRINGS_ESCAPING_H_ #include <cstddef> +#include <optional> #include <string> #include <vector> @@ -190,6 +191,74 @@ // `2*from.size()`. std::string BytesToHexString(absl::string_view from); +// UrlEscape() +// +// Escapes a string so it can be safely used as a value in a URL component by +// replacing all characters that are not "unreserved characters" with +// percent-escapes. See https://tools.ietf.org/html/rfc3986 +// +// Usage note: URLs use "reserved characters" (like ?, &, =, /) as structural +// syntax. This function escapes these syntax characters. The correct use of +// this function is to clean individual URL components *before* assembling them +// into the final URL structure. Do not run it on a fully constructed URL, as +// this will turn structural delimiters into URL component data. +// +// Example (encoding "gift for mom & dad" as a URL query parameter): +// +// std::string url = absl::StrFormat("https://www.google.com/search?q=%s", +// absl::UrlEscape("gift for mom & dad")); +// assert(url == +// "https://www.google.com/search?q=gift%20for%20mom%20%26%20dad"); +[[nodiscard]] std::string UrlEscape(absl::string_view input); + +// UrlUnescape() +// +// Performs the inverse transformation of UrlEscape(), converting each +// percent-encoded sequence of the form "%AB" into the character with the +// hexadecimal value 0xAB. It returns `std::nullopt` if any % is not followed by +// two hexadecimal digits. +// +// UrlUnescape() is identical to UrlUnescapePlus() except that it does not +// unescape '+' to ' '. +[[nodiscard]] std::optional<std::string> UrlUnescape(absl::string_view input); + +// UrlEscapePlus() +// +// Escapes a string so it can be safely used as a value for +// application/x-www-form-urlencoded (HTML form submissions). +// +// Historically web browsers have also used this form of escaping for query +// parameters. +// +// UrlEscapePlus() differs from UrlEscape() in that space (' ') is encoded to +// plus ("+") instead of "%20". According to the URI specification (RFC 3986), +// the correct way to escape a space anywhere in a URL (including the query +// string) is "%20". Using "%20" in a query parameter will work universally. +// +// Some strict URL parsers (especially outside of web browsers/web servers) +// follow RFC 3986 strictly and will treat a literal '+' in the query string as +// a literal plus sign, rather than decoding it to a space. +// +// Recommendation: Use UrlEscapePlus() only if you are specifically implementing +// or interacting with a system that strictly expects +// "application/x-www-form-urlencoded" formatting. For general URL construction, +// UrlEscape() is the correct and safest choice. +// +// Example (encoding "gift for mom & dad" as a URL query parameter): +// +// std::string url = absl::StrFormat("https://www.google.com/search?q=%s", +// absl::UrlEscapePlus( +// "gift for mom & dad")); +// assert(url == "https://www.google.com/search?q=gift+for+mom+%26+dad"); +[[nodiscard]] std::string UrlEscapePlus(absl::string_view input); + +// UrlUnescapePlus() +// +// Performs the inverse transformation of UrlEscapePlus(). It returns +// `std::nullopt` if any % is not followed by two hexadecimal digits. +[[nodiscard]] std::optional<std::string> UrlUnescapePlus( + absl::string_view input); + ABSL_NAMESPACE_END } // namespace absl
diff --git a/absl/strings/escaping_benchmark.cc b/absl/strings/escaping_benchmark.cc index 64b5a41..1fd5b41 100644 --- a/absl/strings/escaping_benchmark.cc +++ b/absl/strings/escaping_benchmark.cc
@@ -18,6 +18,7 @@ #include <string> #include "absl/base/internal/raw_logging.h" +#include "absl/strings/ascii.h" #include "absl/strings/escaping.h" #include "absl/strings/internal/escaping_test_common.h" #include "absl/strings/str_cat.h" @@ -85,6 +86,42 @@ } BENCHMARK(BM_HexStringToBytes_Fail); +static void BM_UrlEscape(benchmark::State& state) { + std::string all; + std::string alnum; + all.reserve(256); + for (int c = 0; c < 256; ++c) { + all.push_back(c); + if (absl::ascii_isalnum(c)) { + alnum.push_back(c); + } + } + + for (auto _ : state) { + benchmark::DoNotOptimize(absl::UrlEscape(all)); + benchmark::DoNotOptimize(absl::UrlEscape(alnum)); + } +} +BENCHMARK(BM_UrlEscape); + +static void BM_UrlEscapePlus(benchmark::State& state) { + std::string all; + std::string alnum; + all.reserve(256); + for (int c = 0; c < 256; ++c) { + all.push_back(c); + if (absl::ascii_isalnum(c)) { + alnum.push_back(c); + } + } + + for (auto _ : state) { + benchmark::DoNotOptimize(absl::UrlEscapePlus(all)); + benchmark::DoNotOptimize(absl::UrlEscapePlus(alnum)); + } +} +BENCHMARK(BM_UrlEscapePlus); + // Used for the CEscape benchmarks const char kStringValueNoEscape[] = "1234567890"; const char kStringValueSomeEscaped[] = "123\n56789\xA1";
diff --git a/absl/strings/escaping_test.cc b/absl/strings/escaping_test.cc index 9651953..a564e83 100644 --- a/absl/strings/escaping_test.cc +++ b/absl/strings/escaping_test.cc
@@ -20,18 +20,24 @@ #include <cstring> #include <initializer_list> #include <memory> +#include <optional> #include <string> #include <vector> +#include "gmock/gmock.h" #include "gtest/gtest.h" #include "absl/log/check.h" -#include "absl/strings/str_cat.h" - +#include "absl/strings/charset.h" #include "absl/strings/internal/escaping_test_common.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/str_format.h" #include "absl/strings/string_view.h" namespace { +using ::testing::Eq; +using ::testing::Optional; + struct epair { std::string escaped; std::string unescaped; @@ -761,4 +767,151 @@ EXPECT_EQ(hex_only_lower, hex_result); } +TEST(UrlEscape, Basics) { + EXPECT_EQ(absl::UrlEscape(""), ""); + EXPECT_THAT(absl::UrlUnescape(""), Optional(Eq(""))); + + EXPECT_EQ(absl::UrlEscape("abc"), "abc"); + EXPECT_THAT(absl::UrlUnescape("abc"), Optional(Eq("abc"))); + + EXPECT_EQ(absl::UrlEscape("a/b"), "a%2Fb"); + EXPECT_THAT(absl::UrlUnescape("a%2Fb"), Optional(Eq("a/b"))); + + EXPECT_EQ(absl::UrlEscape("one two"), "one%20two"); + EXPECT_THAT(absl::UrlUnescape("one%20two"), Optional(Eq("one two"))); + + EXPECT_EQ(absl::UrlEscape("10%"), "10%25"); + EXPECT_THAT(absl::UrlUnescape("10%25"), Optional(Eq("10%"))); + + EXPECT_EQ(absl::UrlEscape(" ?&=#+%!<>#\"{}|\\^[]`☺\t:/@$'()*,;"), + "%20%3F%26%3D%23%2B%25%21%3C%3E%23%22%7B%7D%7C%5C%5E%5B%5D%60%E2%" + "98%BA%09%3A%2F%40%24%27%28%29%2A%2C%3B"); + EXPECT_THAT(absl::UrlUnescape("%20%3F%26%3D%23%2B%25%21%3C%3E%23%22%7B%7D%7C%" + "5C%5E%5B%5D%60%E2%98%BA%" + "09%3A%2F%40%24%27%28%29%2A%2C%3B"), + Optional(Eq(" ?&=#+%!<>#\"{}|\\^[]`☺\t:/@$'()*,;"))); + + // Test all characters. + static constexpr absl::CharSet kDoNotEscape = + absl::CharSet::AsciiAlphanumerics() | absl::CharSet("-._~"); + for (int i = 0; i < 256; ++i) { + char c = static_cast<char>(i); + std::string expected = kDoNotEscape.contains(c) + ? std::string(1, c) + : absl::StrFormat("%%%02X", c); + EXPECT_EQ(absl::UrlEscape(absl::string_view(&c, 1)), expected); + EXPECT_EQ(absl::UrlUnescape(expected), absl::string_view(&c, 1)); + } +} + +TEST(UrlUnescape, SuccessCases) { + EXPECT_THAT(absl::UrlUnescape(""), Optional(Eq(""))); + EXPECT_THAT(absl::UrlUnescape("abc"), Optional(Eq("abc"))); + EXPECT_THAT(absl::UrlUnescape("1%41"), Optional(Eq("1A"))); + EXPECT_THAT(absl::UrlUnescape("1%41%42%43"), Optional(Eq("1ABC"))); + EXPECT_THAT(absl::UrlUnescape("%4a"), Optional(Eq("J"))); + EXPECT_THAT(absl::UrlUnescape("%6F"), Optional(Eq("o"))); + EXPECT_THAT(absl::UrlUnescape("a%20b"), Optional(Eq("a b"))); + EXPECT_THAT(absl::UrlUnescape("a+b"), Optional(Eq("a+b"))); +} + +TEST(UrlUnescape, NotEnoughCharsAfterPercent) { + EXPECT_EQ(absl::UrlUnescape("%"), std::nullopt); + EXPECT_EQ(absl::UrlUnescape("%a"), std::nullopt); + EXPECT_EQ(absl::UrlUnescape("%1"), std::nullopt); + EXPECT_EQ(absl::UrlUnescape("123%45%6"), std::nullopt); +} + +TEST(UrlUnescape, InvalidHexDigits) { + EXPECT_EQ(absl::UrlUnescape("%zzzzz"), std::nullopt); +} + +TEST(UrlUnescape, NoErrorWithNoEscapeSequence) { + // Any string that does not contain '%' should not produce an error, even if + // absl::UrlEscape() would never produce a string with certain characters. + std::string no_percent; + for (int c = 0; c < 256; ++c) { + if (c != '%') { + no_percent.push_back(static_cast<char>(c)); + } + } + EXPECT_THAT(absl::UrlUnescape(no_percent), Optional(no_percent)); +} + +TEST(UrlEscapePlus, Basics) { + EXPECT_EQ(absl::UrlEscapePlus(""), ""); + EXPECT_THAT(absl::UrlUnescapePlus(""), Optional(Eq(""))); + + EXPECT_EQ(absl::UrlEscapePlus("abc"), "abc"); + EXPECT_THAT(absl::UrlUnescapePlus("abc"), Optional(Eq("abc"))); + + EXPECT_EQ(absl::UrlEscapePlus("one two"), "one+two"); + EXPECT_THAT(absl::UrlUnescapePlus("one+two"), Optional(Eq("one two"))); + + EXPECT_EQ(absl::UrlEscapePlus("gift for mom & dad"), "gift+for+mom+%26+dad"); + EXPECT_THAT(absl::UrlUnescapePlus("gift+for+mom+%26+dad"), + Optional(Eq("gift for mom & dad"))); + + EXPECT_EQ(absl::UrlEscapePlus("10%"), "10%25"); + EXPECT_THAT(absl::UrlUnescapePlus("10%25"), Optional(Eq("10%"))); + + EXPECT_EQ(absl::UrlEscapePlus(" ?&=#+%!<>#\"{}|\\^[]`☺\t:/@$'()*,;"), + "+%3F%26%3D%23%2B%25%21%3C%3E%23%22%7B%7D%7C%5C%5E%5B%5D%60%E2%" + "98%BA%09%3A%2F%40%24%27%28%29%2A%2C%3B"); + EXPECT_THAT(absl::UrlUnescapePlus("+%3F%26%3D%23%2B%25%21%3C%3E%23%22%7B%7D%" + "7C%5C%5E%5B%5D%60%E2%98%BA%" + "09%3A%2F%40%24%27%28%29%2A%2C%3B"), + Optional(Eq(" ?&=#+%!<>#\"{}|\\^[]`☺\t:/@$'()*,;"))); + + // Test all characters. + static constexpr absl::CharSet kDoNotEscape = + absl::CharSet::AsciiAlphanumerics() | absl::CharSet("-._~"); + for (int i = 0; i < 256; ++i) { + char c = static_cast<char>(i); + std::string expected = kDoNotEscape.contains(c) + ? std::string(1, c) + : absl::StrFormat("%%%02X", c); + if (c == ' ') expected = '+'; + EXPECT_EQ(absl::UrlEscapePlus(absl::string_view(&c, 1)), expected); + EXPECT_EQ(absl::UrlUnescapePlus(expected), absl::string_view(&c, 1)); + } +} + +TEST(UrlUnescapePlus, SuccessCases) { + EXPECT_THAT(absl::UrlUnescapePlus(""), Optional(Eq(""))); + EXPECT_THAT(absl::UrlUnescapePlus("abc"), Optional(Eq("abc"))); + EXPECT_THAT(absl::UrlUnescapePlus("1%41"), Optional(Eq("1A"))); + EXPECT_THAT(absl::UrlUnescapePlus("1%41%42%43"), Optional(Eq("1ABC"))); + EXPECT_THAT(absl::UrlUnescapePlus("%4a"), Optional(Eq("J"))); + EXPECT_THAT(absl::UrlUnescapePlus("%6F"), Optional(Eq("o"))); + EXPECT_THAT(absl::UrlUnescapePlus("a%20b"), Optional(Eq("a b"))); + EXPECT_THAT(absl::UrlUnescapePlus("a+b"), Optional(Eq("a b"))); +} + +TEST(UrlUnescapePlus, NotEnoughCharsAfterPercent) { + EXPECT_EQ(absl::UrlUnescapePlus("%"), std::nullopt); + EXPECT_EQ(absl::UrlUnescapePlus("%a"), std::nullopt); + EXPECT_EQ(absl::UrlUnescapePlus("%1"), std::nullopt); + EXPECT_EQ(absl::UrlUnescapePlus("123%45%6"), std::nullopt); +} + +TEST(UrlUnescapePlus, InvalidHexDigits) { + EXPECT_EQ(absl::UrlUnescapePlus("%zzzzz"), std::nullopt); +} + +TEST(UrlUnescapePlus, NoErrorWithNoEscapeSequence) { + // Any string that does not contain '%' should not produce an error, even if + // absl::UrlEscapePlus() would never produce a string with certain + // characters. + std::string no_percent; + std::string no_percent_expected; + for (int c = 0; c < 256; ++c) { + if (c != '%') { + no_percent.push_back(static_cast<char>(c)); + no_percent_expected.push_back(c != '+' ? static_cast<char>(c) : ' '); + } + } + EXPECT_THAT(absl::UrlUnescapePlus(no_percent), Optional(no_percent_expected)); +} + } // namespace