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