From 2fd3213f1faa030503887e23f352a88ee774e0b6 Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Sun, 19 Jul 2026 21:08:18 -0300 Subject: [PATCH 1/2] Implement helpers for URI form percentage unescaping Signed-off-by: Juan Cruz Viotti --- src/core/uri/escape.cc | 31 ++++++ src/core/uri/include/sourcemeta/core/uri.h | 20 ++++ test/uri/CMakeLists.txt | 1 + test/uri/uri_unescape_form_test.cc | 115 +++++++++++++++++++++ 4 files changed, 167 insertions(+) create mode 100644 test/uri/uri_unescape_form_test.cc diff --git a/src/core/uri/escape.cc b/src/core/uri/escape.cc index fadd35b4f9..bbed5198e4 100644 --- a/src/core/uri/escape.cc +++ b/src/core/uri/escape.cc @@ -67,4 +67,35 @@ auto URI::unescape(const std::string_view input) -> std::string { return result; } +auto URI::unescape_form(const std::string_view input, std::string &output) + -> bool { + const auto base{output.size()}; + output.reserve(base + input.size()); + for (std::size_t position = 0; position < input.size();) { + const auto character{input[position]}; + if (character == '+') { + output += ' '; + position += 1; + } else if (character == URI_PERCENT) { + const auto high{position + 2 < input.size() + ? hex_digit_value(input[position + 1]) + : static_cast(-1)}; + const auto low{high < 0 ? static_cast(-1) + : hex_digit_value(input[position + 2])}; + if (low < 0) { + output.resize(base); + return false; + } + + output += static_cast((high << 4) | low); + position += 3; + } else { + output += character; + position += 1; + } + } + + return true; +} + } // namespace sourcemeta::core diff --git a/src/core/uri/include/sourcemeta/core/uri.h b/src/core/uri/include/sourcemeta/core/uri.h index 5266299003..08275a5e06 100644 --- a/src/core/uri/include/sourcemeta/core/uri.h +++ b/src/core/uri/include/sourcemeta/core/uri.h @@ -807,6 +807,26 @@ class SOURCEMETA_CORE_URI_EXPORT URI { /// ``` [[nodiscard]] static auto unescape(std::string_view input) -> std::string; + /// Decode an "application/x-www-form-urlencoded" component (RFC 6749 Appendix + /// B and the HTML URL-encoded form syntax), appending the decoded bytes to + /// the output. Each "+" becomes a space and each "%" followed by two + /// hexadecimal digits becomes its octet. A "%" that is not followed by two + /// hexadecimal digits is rejected, returning false with the output restored + /// to its original contents. The output must not alias the input. For + /// example: + /// + /// ```cpp + /// #include + /// #include + /// #include + /// + /// std::string output; + /// assert(sourcemeta::core::URI::unescape_form("a+b%2Fc", output)); + /// assert(output == "a b/c"); + /// ``` + static auto unescape_form(std::string_view input, std::string &output) + -> bool; + /// Remove the "." and ".." segments from a URI path per RFC 3986 Section /// 5.2.4, preserving leading ".." segments in a relative path. For example: /// diff --git a/test/uri/CMakeLists.txt b/test/uri/CMakeLists.txt index da548c87cf..d4bb211ab8 100644 --- a/test/uri/CMakeLists.txt +++ b/test/uri/CMakeLists.txt @@ -32,6 +32,7 @@ sourcemeta_test(NAMESPACE sourcemeta PROJECT core NAME uri uri_canonicalize_test.cc uri_escape_test.cc uri_unescape_test.cc + uri_unescape_form_test.cc uri_normalize_path_test.cc uri_resolve_from_test.cc uri_relative_to_test.cc diff --git a/test/uri/uri_unescape_form_test.cc b/test/uri/uri_unescape_form_test.cc new file mode 100644 index 0000000000..4bebba5e92 --- /dev/null +++ b/test/uri/uri_unescape_form_test.cc @@ -0,0 +1,115 @@ +#include +#include + +#include // std::string + +TEST(plain_value_passes_through) { + std::string output; + EXPECT_TRUE(sourcemeta::core::URI::unescape_form("foobar", output)); + EXPECT_EQ(output, "foobar"); +} + +TEST(plus_becomes_space) { + std::string output; + EXPECT_TRUE(sourcemeta::core::URI::unescape_form("foo+bar", output)); + EXPECT_EQ(output, "foo bar"); +} + +TEST(percent_twenty_becomes_space) { + std::string output; + EXPECT_TRUE(sourcemeta::core::URI::unescape_form("foo%20bar", output)); + EXPECT_EQ(output, "foo bar"); +} + +TEST(percent_two_f_becomes_slash) { + std::string output; + EXPECT_TRUE(sourcemeta::core::URI::unescape_form("path%2Fto", output)); + EXPECT_EQ(output, "path/to"); +} + +TEST(uppercase_hex) { + std::string output; + EXPECT_TRUE(sourcemeta::core::URI::unescape_form("%C3%A9", output)); + EXPECT_EQ(output, "\xc3\xa9"); +} + +TEST(lowercase_hex) { + std::string output; + EXPECT_TRUE(sourcemeta::core::URI::unescape_form("%c3%a9", output)); + EXPECT_EQ(output, "\xc3\xa9"); +} + +TEST(encoded_plus_stays_a_plus) { + // "%2B" is a literal "+", distinct from a "+" that means a space, which is + // the round trip the plain percent decoder cannot preserve + std::string output; + EXPECT_TRUE(sourcemeta::core::URI::unescape_form("a%2Bb", output)); + EXPECT_EQ(output, "a+b"); +} + +TEST(plus_and_encoded_plus_together) { + std::string output; + EXPECT_TRUE(sourcemeta::core::URI::unescape_form("a+b%2Bc", output)); + EXPECT_EQ(output, "a b+c"); +} + +TEST(mixed_plus_and_percent) { + std::string output; + EXPECT_TRUE(sourcemeta::core::URI::unescape_form("a+b%20c%2Fd", output)); + EXPECT_EQ(output, "a b c/d"); +} + +TEST(unreserved_characters_pass_through) { + std::string output; + EXPECT_TRUE(sourcemeta::core::URI::unescape_form("ABCabc0123-._~", output)); + EXPECT_EQ(output, "ABCabc0123-._~"); +} + +TEST(empty_input_succeeds) { + std::string output; + EXPECT_TRUE(sourcemeta::core::URI::unescape_form("", output)); + EXPECT_TRUE(output.empty()); +} + +TEST(appends_to_an_existing_output) { + std::string output{"key="}; + EXPECT_TRUE(sourcemeta::core::URI::unescape_form("a+b", output)); + EXPECT_EQ(output, "key=a b"); +} + +TEST(rejects_a_trailing_percent) { + std::string output; + EXPECT_FALSE(sourcemeta::core::URI::unescape_form("abc%", output)); +} + +TEST(rejects_an_incomplete_sequence) { + std::string output; + EXPECT_FALSE(sourcemeta::core::URI::unescape_form("a%2", output)); +} + +TEST(rejects_a_non_hex_digit) { + std::string output; + EXPECT_FALSE(sourcemeta::core::URI::unescape_form("a%2Gb", output)); +} + +TEST(rejects_a_percent_before_the_end) { + std::string output; + EXPECT_FALSE(sourcemeta::core::URI::unescape_form("a%b", output)); +} + +TEST(restores_the_output_on_failure) { + // The prior content and every byte decoded before the malformed escape are + // rolled back, so a failed decode never leaves a partial value behind + std::string output{"prefix:"}; + EXPECT_FALSE(sourcemeta::core::URI::unescape_form("good%20then%2", output)); + EXPECT_EQ(output, "prefix:"); +} + +TEST(decodes_a_null_byte) { + std::string output; + EXPECT_TRUE(sourcemeta::core::URI::unescape_form("a%00b", output)); + EXPECT_EQ(output.size(), 3); + EXPECT_EQ(output.front(), 'a'); + EXPECT_EQ(static_cast(output[1]), 0x00); + EXPECT_EQ(output.back(), 'b'); +} From 915a07e95a3f4f73f6981db1f80ad65a0e4dc52a Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Sun, 19 Jul 2026 23:05:01 -0300 Subject: [PATCH 2/2] Fix Signed-off-by: Juan Cruz Viotti --- src/core/uri/include/sourcemeta/core/uri.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/uri/include/sourcemeta/core/uri.h b/src/core/uri/include/sourcemeta/core/uri.h index 08275a5e06..dab96e00d0 100644 --- a/src/core/uri/include/sourcemeta/core/uri.h +++ b/src/core/uri/include/sourcemeta/core/uri.h @@ -824,8 +824,8 @@ class SOURCEMETA_CORE_URI_EXPORT URI { /// assert(sourcemeta::core::URI::unescape_form("a+b%2Fc", output)); /// assert(output == "a b/c"); /// ``` - static auto unescape_form(std::string_view input, std::string &output) - -> bool; + [[nodiscard]] static auto unescape_form(std::string_view input, + std::string &output) -> bool; /// Remove the "." and ".." segments from a URI path per RFC 3986 Section /// 5.2.4, preserving leading ".." segments in a relative path. For example: