From 8ad404245ae9eea7be54020141a9328975a287de Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Sun, 9 Aug 2026 14:58:28 -0300 Subject: [PATCH 1/8] Make `URI::relative_to` the strict inverse of `URI::resolve_from` Signed-off-by: Juan Cruz Viotti --- src/core/uri/include/sourcemeta/core/uri.h | 32 +- src/core/uri/recompose.cc | 28 +- src/core/uri/resolution.cc | 70 +-- test/uri/CMakeLists.txt | 1 + test/uri/uri_rebase_test.cc | 40 ++ test/uri/uri_recompose_relative_test.cc | 6 +- test/uri/uri_relative_to_test.cc | 53 +- .../uri/uri_relativization_round_trip_test.cc | 569 ++++++++++++++++++ 8 files changed, 721 insertions(+), 78 deletions(-) create mode 100644 test/uri/uri_relativization_round_trip_test.cc diff --git a/src/core/uri/include/sourcemeta/core/uri.h b/src/core/uri/include/sourcemeta/core/uri.h index d0c5e9397e..61d54c7308 100644 --- a/src/core/uri/include/sourcemeta/core/uri.h +++ b/src/core/uri/include/sourcemeta/core/uri.h @@ -656,22 +656,35 @@ class SOURCEMETA_CORE_URI_EXPORT URI { /// ``` auto resolve_from(const URI &base) -> URI &; - /// Attempt to resolve a URI relative to another URI. If the latter URI is not - /// a base for the former, leave the URI intact. For example: + /// Express a URI as a relative reference against a base URI, such that + /// resolving the result against that base reproduces this URI: + /// + /// ``` + /// resolve_from(relative_to(target, base), base) == target + /// ``` + /// + /// That equation is the definition of a correct result, as RFC 3986 states + /// how to resolve a reference but never how to compute one. It holds when + /// both URIs are absolute and the target path carries no dot segments, which + /// resolution always removes and so can never reproduce. When no such + /// reference exists, the URI is left intact, which satisfies the equation + /// too. For example: /// /// ```cpp /// #include /// #include /// - /// const sourcemeta::core::URI base{"https://www.sourcemeta.com"}; + /// const sourcemeta::core::URI base{"https://www.sourcemeta.com/"}; /// sourcemeta::core::URI result{"https://www.sourcemeta.com/foo"}; /// result.relative_to(base); /// assert(result.recompose() == "foo"); /// ``` auto relative_to(const URI &base) -> URI &; - /// Attempt to change the base of a URI. If the URI is not relative to - /// the former, leave the URI intact. For example: + /// Move a URI that lies under a base to the same position under a new base. + /// A URI that is neither the base nor under it is left intact, and so is one + /// that only shares a textual prefix without matching whole path segments. + /// For example: /// /// ```cpp /// #include @@ -685,9 +698,9 @@ class SOURCEMETA_CORE_URI_EXPORT URI { /// ``` auto rebase(const URI &base, const URI &new_base) -> URI &; - /// Attempt to change the base of a URI, moving components out of - /// `new_base` rather than copying them. If the URI is not relative to - /// the former base, leave the URI intact. For example: + /// Move a URI that lies under a base to the same position under a new base, + /// taking components out of `new_base` rather than copying them. A URI that + /// is neither the base nor under it is left intact. For example: /// /// ```cpp /// #include @@ -1114,6 +1127,9 @@ class SOURCEMETA_CORE_URI_EXPORT URI { private: auto parse(std::string_view input) -> void; + [[nodiscard]] auto path_under(const URI &base) const + -> std::optional; + // Exporting symbols that depends on the standard C++ library is considered // safe. // https://learn.microsoft.com/en-us/cpp/error-messages/compiler-warnings/compiler-warning-level-2-c4275?view=msvc-170&redirectedfrom=MSDN diff --git a/src/core/uri/recompose.cc b/src/core/uri/recompose.cc index e746ea5644..f7f1679d85 100644 --- a/src/core/uri/recompose.cc +++ b/src/core/uri/recompose.cc @@ -144,25 +144,15 @@ auto append_disambiguated_path(std::string &output, const auto first_segment_length{first_slash == std::string_view::npos ? path_value.size() : first_slash}; - const auto first_segment{path_value.substr(0, first_segment_length)}; - if (first_segment.find(':') != std::string_view::npos) { - std::string encoded; - encoded.reserve(first_segment_length + 4); - for (const char character : first_segment) { - if (character == ':') { - encoded += "%3A"; - } else { - encoded += character; - } - } - - escape_component_to_string(output, encoded, URIEscapeMode::Path, iri); - if (first_slash != std::string_view::npos) { - escape_component_to_string(output, path_value.substr(first_slash), - URIEscapeMode::Path, iri); - } - - return; + // RFC 3986 Section 4.2: "A path segment that contains a colon character + // cannot be used as the first segment of a relative-path reference, as it + // would be mistaken for a scheme name. Such a segment must be preceded by + // a dot-segment". Percent encoding the colon would avoid the same misparse + // but would name a different path, since Section 6.2.2.2 only equates + // percent-encoded unreserved characters + if (path_value.substr(0, first_segment_length).find(':') != + std::string_view::npos) { + output += "./"; } } diff --git a/src/core/uri/resolution.cc b/src/core/uri/resolution.cc index cb52fb6b23..274f58d874 100644 --- a/src/core/uri/resolution.cc +++ b/src/core/uri/resolution.cc @@ -227,35 +227,11 @@ auto URI::relative_to(const URI &base) -> URI & { return *this; } - // Case 2: Check if this_path starts with base_path followed by "/" - // This handles: base="/foo" and this="/foo/bar" = "bar" - // But NOT: base="/spec" and this="/spec/" (different resources) - // RFC 3986 Section 5.2.2 uses a reference path that starts with a slash as - // is, so a remainder that begins with one would drop the base prefix rather - // than name something below it - const std::string base_with_slash = - base_path.ends_with('/') ? base_path : base_path + "/"; - if (this_path.starts_with(base_with_slash) && - this_path.length() > base_with_slash.length() && - this_path[base_with_slash.length()] != '/') { - auto relative_path = this_path.substr(base_with_slash.length()); - - this->scheme_.reset(); - this->userinfo_.reset(); - this->host_.reset(); - this->port_.reset(); - this->path_ = relative_path.empty() - ? std::nullopt - : std::optional{relative_path}; - - return *this; - } - // Find last slash positions (needed for multiple cases below) const auto base_last_slash = base_path.rfind('/'); const auto this_last_slash = this_path.rfind('/'); - // Case 3: Check if both paths share the same parent directory (siblings) + // Case 2: Check if both paths share the same parent directory (siblings) // This handles: base="/test/bar.json" and this="/test/foo.json" = // "foo.json" if (base_last_slash != std::string::npos && @@ -280,7 +256,7 @@ auto URI::relative_to(const URI &base) -> URI & { } } - // Case 4: General case - compute relative path using .. segments + // Case 3: General case - compute relative path using .. segments // This handles cases like: base="/schemas/foo.json" and this="/bundling/bar" // Result should be "../bundling/bar" // Note: We don't make URIs relative if the target is just a shallow path @@ -381,15 +357,26 @@ auto merge_new_base_path(std::optional &target_path, } // namespace +auto URI::path_under(const URI &base) const -> std::optional { + if (this->scheme_ != base.scheme_ || this->userinfo_ != base.userinfo_ || + this->host_ != base.host_ || this->port_ != base.port_) { + return std::nullopt; + } + + return URI::strip_path_prefix(this->path_.value_or(""), + base.path_.value_or("")); +} + auto URI::rebase(const URI &base, const URI &new_base) -> URI & { - this->relative_to(base); - if (!this->is_relative()) { + auto suffix{this->path_under(base)}; + if (!suffix.has_value()) { return *this; } - auto saved_path = std::move(this->path_); - auto saved_fragment = std::move(this->fragment_); - auto saved_query = std::move(this->query_); + std::optional relative_path; + if (!suffix.value().empty()) { + relative_path = std::move(suffix.value()); + } this->scheme_ = new_base.scheme_; this->userinfo_ = new_base.userinfo_; @@ -401,23 +388,21 @@ auto URI::rebase(const URI &base, const URI &new_base) -> URI & { std::optional new_base_path_copy{new_base.path_}; merge_new_base_path(this->path_, std::move(new_base_path_copy), - std::move(saved_path)); - - this->fragment_ = std::move(saved_fragment); - this->query_ = std::move(saved_query); + std::move(relative_path)); return *this; } auto URI::rebase(const URI &base, URI &&new_base) -> URI & { - this->relative_to(base); - if (!this->is_relative()) { + auto suffix{this->path_under(base)}; + if (!suffix.has_value()) { return *this; } - auto saved_path = std::move(this->path_); - auto saved_fragment = std::move(this->fragment_); - auto saved_query = std::move(this->query_); + std::optional relative_path; + if (!suffix.value().empty()) { + relative_path = std::move(suffix.value()); + } this->scheme_ = std::move(new_base.scheme_); this->userinfo_ = std::move(new_base.userinfo_); @@ -428,10 +413,7 @@ auto URI::rebase(const URI &base, URI &&new_base) -> URI & { this->iri_ = this->iri_ || new_base.iri_; merge_new_base_path(this->path_, std::move(new_base.path_), - std::move(saved_path)); - - this->fragment_ = std::move(saved_fragment); - this->query_ = std::move(saved_query); + std::move(relative_path)); return *this; } diff --git a/test/uri/CMakeLists.txt b/test/uri/CMakeLists.txt index d9c5a5964a..b730305102 100644 --- a/test/uri/CMakeLists.txt +++ b/test/uri/CMakeLists.txt @@ -41,6 +41,7 @@ sourcemeta_test(NAMESPACE sourcemeta PROJECT core NAME uri uri_normalize_path_test.cc uri_resolve_from_test.cc uri_relative_to_test.cc + uri_relativization_round_trip_test.cc uri_extension_test.cc uri_user_info_test.cc uri_is_iri_test.cc diff --git a/test/uri/uri_rebase_test.cc b/test/uri/uri_rebase_test.cc index abb68389e5..9ec5305bc0 100644 --- a/test/uri/uri_rebase_test.cc +++ b/test/uri/uri_rebase_test.cc @@ -56,3 +56,43 @@ TEST(iri_flag_propagates_from_new_base) { EXPECT_EQ(uri.recompose(), "https://example.com/caf\xC3\xA9/file"); EXPECT_TRUE(uri.is_internationalized()); } + +TEST(sibling_of_base_is_left_intact) { + sourcemeta::core::URI uri{"https://example.com/foo/other"}; + const sourcemeta::core::URI base{"https://example.com/foo/bar"}; + const sourcemeta::core::URI new_base{"/qux"}; + uri.rebase(base, new_base); + EXPECT_EQ(uri.recompose(), "https://example.com/foo/other"); +} + +TEST(unrelated_directory_is_left_intact) { + sourcemeta::core::URI uri{"https://example.com/qux/x"}; + const sourcemeta::core::URI base{"https://example.com/foo/bar"}; + const sourcemeta::core::URI new_base{"/new"}; + uri.rebase(base, new_base); + EXPECT_EQ(uri.recompose(), "https://example.com/qux/x"); +} + +TEST(partial_segment_match_is_left_intact) { + sourcemeta::core::URI uri{"https://example.com/foobar/baz"}; + const sourcemeta::core::URI base{"https://example.com/foo"}; + const sourcemeta::core::URI new_base{"/qux"}; + uri.rebase(base, new_base); + EXPECT_EQ(uri.recompose(), "https://example.com/foobar/baz"); +} + +TEST(base_without_path_moves_whole_path) { + sourcemeta::core::URI uri{"https://example.com/foo/bar"}; + const sourcemeta::core::URI base{"https://example.com"}; + const sourcemeta::core::URI new_base{"/qux"}; + uri.rebase(base, new_base); + EXPECT_EQ(uri.recompose(), "/qux/foo/bar"); +} + +TEST(different_host_is_left_intact) { + sourcemeta::core::URI uri{"https://other.com/foo/bar"}; + const sourcemeta::core::URI base{"https://example.com/foo"}; + const sourcemeta::core::URI new_base{"/qux"}; + uri.rebase(base, new_base); + EXPECT_EQ(uri.recompose(), "https://other.com/foo/bar"); +} diff --git a/test/uri/uri_recompose_relative_test.cc b/test/uri/uri_recompose_relative_test.cc index 84d8c0dab6..007323be68 100644 --- a/test/uri/uri_recompose_relative_test.cc +++ b/test/uri/uri_recompose_relative_test.cc @@ -77,14 +77,14 @@ TEST(query_and_fragment_no_path) { EXPECT_EQ(uri.recompose_relative(), "?x=1#bar"); } -TEST(path_noscheme_first_segment_colon_encoded) { +TEST(path_noscheme_first_segment_colon_takes_a_dot_segment) { const sourcemeta::core::URI uri{"urn:foo:bar"}; - EXPECT_EQ(uri.recompose_relative(), "foo%3Abar"); + EXPECT_EQ(uri.recompose_relative(), "./foo:bar"); } TEST(path_noscheme_colon_only_in_first_segment) { const sourcemeta::core::URI uri{"urn:foo:bar/baz:qux"}; - EXPECT_EQ(uri.recompose_relative(), "foo%3Abar/baz:qux"); + EXPECT_EQ(uri.recompose_relative(), "./foo:bar/baz:qux"); } TEST(path_absolute_first_segment_colon_preserved) { diff --git a/test/uri/uri_relative_to_test.cc b/test/uri/uri_relative_to_test.cc index 0b91e98daa..506187ffcf 100644 --- a/test/uri/uri_relative_to_test.cc +++ b/test/uri/uri_relative_to_test.cc @@ -19,14 +19,14 @@ TEST(absolute_absolute_base_true_3) { const sourcemeta::core::URI base{"https://www.example.com/foo"}; sourcemeta::core::URI uri{"https://www.example.com/foo/bar?q=1"}; uri.relative_to(base); - EXPECT_EQ(uri.recompose(), "bar?q=1"); + EXPECT_EQ(uri.recompose(), "foo/bar?q=1"); } TEST(absolute_absolute_base_true_4) { const sourcemeta::core::URI base{"https://www.example.com/foo"}; sourcemeta::core::URI uri{"https://www.example.com/foo/bar#baz"}; uri.relative_to(base); - EXPECT_EQ(uri.recompose(), "bar#baz"); + EXPECT_EQ(uri.recompose(), "foo/bar#baz"); } TEST(absolute_absolute_base_false_1) { @@ -204,7 +204,7 @@ TEST(file_subdirectory) { const sourcemeta::core::URI base{"file:///home/user/schemas"}; sourcemeta::core::URI uri{"file:///home/user/schemas/sub/test.json"}; uri.relative_to(base); - EXPECT_EQ(uri.recompose(), "sub/test.json"); + EXPECT_EQ(uri.recompose(), "schemas/sub/test.json"); } TEST(file_parent_directory) { @@ -246,7 +246,7 @@ TEST(file_windows_subdirectory) { const sourcemeta::core::URI base{"file:///C:/Users/user/schemas"}; sourcemeta::core::URI uri{"file:///C:/Users/user/schemas/sub/test.json"}; uri.relative_to(base); - EXPECT_EQ(uri.recompose(), "sub/test.json"); + EXPECT_EQ(uri.recompose(), "schemas/sub/test.json"); } TEST(file_windows_parent_directory) { @@ -546,3 +546,48 @@ TEST(target_is_absolute_path_of_base_without_authority_resolves_back) { resolved.resolve_from(base); EXPECT_EQ(resolved.recompose(), "schema:/foo"); } + +TEST(sibling_with_colon_in_first_segment_resolves_back) { + const sourcemeta::core::URI base{"file:///C:/Users/user/schemas/base.json"}; + sourcemeta::core::URI uri{"file:///C:/Users/user/schemas/D:foo.json"}; + uri.relative_to(base); + EXPECT_EQ(uri.recompose(), "./D:foo.json"); + sourcemeta::core::URI resolved{uri.recompose()}; + resolved.resolve_from(base); + EXPECT_EQ(resolved.recompose(), "file:///C:/Users/user/schemas/D:foo.json"); +} + +TEST(descendant_of_base_names_the_base_segment) { + const sourcemeta::core::URI base{"https://example.com/foo"}; + sourcemeta::core::URI uri{"https://example.com/foo/bar"}; + uri.relative_to(base); + EXPECT_EQ(uri.recompose(), "foo/bar"); +} + +TEST(descendant_of_base_resolves_back) { + const sourcemeta::core::URI base{"https://example.com/foo"}; + sourcemeta::core::URI uri{"https://example.com/foo/bar"}; + uri.relative_to(base); + sourcemeta::core::URI resolved{uri.recompose()}; + resolved.resolve_from(base); + EXPECT_EQ(resolved.recompose(), "https://example.com/foo/bar"); +} + +TEST(deep_descendant_of_base_resolves_back) { + const sourcemeta::core::URI base{"file:///home/user/schemas"}; + sourcemeta::core::URI uri{"file:///home/user/schemas/sub/test.json"}; + uri.relative_to(base); + sourcemeta::core::URI resolved{uri.recompose()}; + resolved.resolve_from(base); + EXPECT_EQ(resolved.recompose(), "file:///home/user/schemas/sub/test.json"); +} + +TEST(descendant_of_base_with_trailing_slash_resolves_back) { + const sourcemeta::core::URI base{"https://example.com/foo/"}; + sourcemeta::core::URI uri{"https://example.com/foo/bar"}; + uri.relative_to(base); + EXPECT_EQ(uri.recompose(), "bar"); + sourcemeta::core::URI resolved{uri.recompose()}; + resolved.resolve_from(base); + EXPECT_EQ(resolved.recompose(), "https://example.com/foo/bar"); +} diff --git a/test/uri/uri_relativization_round_trip_test.cc b/test/uri/uri_relativization_round_trip_test.cc new file mode 100644 index 0000000000..ddaea7bc1e --- /dev/null +++ b/test/uri/uri_relativization_round_trip_test.cc @@ -0,0 +1,569 @@ +#include +#include + +#include // std::string + +// RFC 3986 defines resolution but no way to compute a relative reference, so +// the correctness of relativization is defined by resolution undoing it: +// +// resolve_from(relative_to(target, base), base) == target +// +// Resolution is the fixed side of that equation. Section 5.2.2 specifies it +// exactly, and every one of the 41 examples in the Section 5.4 tables is +// asserted in the resolution tests, so it can be treated as the reference +// implementation that relativization is measured against rather than as +// another moving part. +// +// Both inputs must be absolute, as Section 5.1 requires an absolute base, and +// the target path must be free of dot segments, as Section 5.2.2 removes those +// on every resolution branch and no reference can reproduce them. Every case in +// the relativization tests that meets those conditions has an entry here. A +// failure means the implementation is wrong, not the expectation. + +static auto round_trip(const std::string &base_string, + const std::string &target_string) -> std::string { + const sourcemeta::core::URI base{base_string}; + sourcemeta::core::URI target{target_string}; + target.relative_to(base); + sourcemeta::core::URI resolved{target.recompose()}; + resolved.resolve_from(base); + return resolved.recompose(); +} + +// The parser decodes percent-encoded unreserved characters, so comparing +// against the raw target string would fail for reasons unrelated to +// relativization +static auto normalised(const std::string &input) -> std::string { + return sourcemeta::core::URI{input}.recompose(); +} + +TEST(absolute_absolute_base_true_1) { + EXPECT_EQ( + round_trip("https://www.example.com", "https://www.example.com/foo"), + normalised("https://www.example.com/foo")); +} + +TEST(absolute_absolute_base_true_2) { + EXPECT_EQ( + round_trip("https://www.example.com/foo", "https://www.example.com/foo"), + normalised("https://www.example.com/foo")); +} + +TEST(absolute_absolute_base_true_3) { + EXPECT_EQ(round_trip("https://www.example.com/foo", + "https://www.example.com/foo/bar?q=1"), + normalised("https://www.example.com/foo/bar?q=1")); +} + +TEST(absolute_absolute_base_true_4) { + EXPECT_EQ(round_trip("https://www.example.com/foo", + "https://www.example.com/foo/bar#baz"), + normalised("https://www.example.com/foo/bar#baz")); +} + +TEST(absolute_absolute_base_false_1) { + EXPECT_EQ( + round_trip("https://www.example.com/foo", "http://www.example.com/foo"), + normalised("http://www.example.com/foo")); +} + +TEST(absolute_absolute_base_false_2) { + EXPECT_EQ( + round_trip("https://www.example.com/foo", "https://www.example.com"), + normalised("https://www.example.com")); +} + +TEST(absolute_absolute_base_false_3) { + EXPECT_EQ(round_trip("https://www.example.com/foo/bar", + "https://www.example.com/foo"), + normalised("https://www.example.com/foo")); +} + +TEST(absolute_absolute_base_false_4) { + EXPECT_EQ(round_trip("https://foo.com", "https://bar.com"), + normalised("https://bar.com")); +} + +TEST(absolute_absolute_base_false_different_ports) { + EXPECT_EQ(round_trip("http://localhost:8000", + "http://localhost:9000/schemas/test.json"), + normalised("http://localhost:9000/schemas/test.json")); +} + +TEST(absolute_absolute_base_false_different_userinfo) { + EXPECT_EQ(round_trip("https://alice@example.com/foo", + "https://bob@example.com/foo/bar"), + normalised("https://bob@example.com/foo/bar")); +} + +TEST(absolute_absolute_base_false_userinfo_vs_none) { + EXPECT_EQ(round_trip("https://example.com/foo", + "https://alice@example.com/foo/bar"), + normalised("https://alice@example.com/foo/bar")); +} + +TEST(urn_1) { + EXPECT_EQ(round_trip("schema:", "schema:myschema"), + normalised("schema:myschema")); +} + +TEST(absolute_absolute_trailing_slash) { + EXPECT_EQ( + round_trip("https://github.com/apis-json/api-json/blob/develop/spec", + "https://github.com/apis-json/api-json/blob/develop/spec/"), + normalised("https://github.com/apis-json/api-json/blob/develop/spec/")); +} + +TEST(target_is_prefix_of_base_parent) { + EXPECT_EQ( + round_trip("https://example.com/foo/bar/baz", "https://example.com/foo"), + normalised("https://example.com/foo")); +} + +TEST(target_is_one_level_up_at_root) { + EXPECT_EQ( + round_trip("https://example.com/foo/bar", "https://example.com/foo"), + normalised("https://example.com/foo")); +} + +TEST(target_is_root) { + EXPECT_EQ(round_trip("https://example.com/foo/bar", "https://example.com/"), + normalised("https://example.com/")); +} + +TEST(base_ends_with_slash) { + EXPECT_EQ( + round_trip("https://example.com/foo/", "https://example.com/foo/bar"), + normalised("https://example.com/foo/bar")); +} + +TEST(base_root_only) { + EXPECT_EQ(round_trip("https://example.com/", "https://example.com/foo"), + normalised("https://example.com/foo")); +} + +TEST(sibling_paths_same_directory) { + EXPECT_EQ(round_trip("https://example.com/schemas/bar.json", + "https://example.com/schemas/foo.json"), + normalised("https://example.com/schemas/foo.json")); +} + +TEST(double_slash_with_trailing_slash) { + EXPECT_EQ(round_trip("https://example.com/slash/", + "https://example.com/slash/file.json"), + normalised("https://example.com/slash/file.json")); +} + +TEST(different_directories_same_host_needs_dotdot) { + EXPECT_EQ(round_trip("https://example.com/schemas/with-rebase-same-host.json", + "https://example.com/bundling/single"), + normalised("https://example.com/bundling/single")); +} + +TEST(different_directories_same_host_needs_dotdot_2) { + EXPECT_EQ(round_trip("https://example.com/foo/bar/baz.json", + "https://example.com/qux/test.json"), + normalised("https://example.com/qux/test.json")); +} + +TEST(different_directories_same_host_needs_dotdot_3) { + EXPECT_EQ(round_trip("https://example.com/a/b/c.json", + "https://example.com/d.json"), + normalised("https://example.com/d.json")); +} + +TEST(file_same_directory) { + EXPECT_EQ(round_trip("file:///home/user/schemas/base.json", + "file:///home/user/schemas/other.json"), + normalised("file:///home/user/schemas/other.json")); +} + +TEST(file_subdirectory) { + EXPECT_EQ(round_trip("file:///home/user/schemas", + "file:///home/user/schemas/sub/test.json"), + normalised("file:///home/user/schemas/sub/test.json")); +} + +TEST(file_parent_directory) { + EXPECT_EQ(round_trip("file:///home/user/schemas/sub/base.json", + "file:///home/user/schemas/other.json"), + normalised("file:///home/user/schemas/other.json")); +} + +TEST(file_different_root) { + EXPECT_EQ(round_trip("file:///home/user/schemas/base.json", + "file:///var/data/test.json"), + normalised("file:///var/data/test.json")); +} + +TEST(file_same_file) { + EXPECT_EQ(round_trip("file:///home/user/schemas/base.json", + "file:///home/user/schemas/base.json"), + normalised("file:///home/user/schemas/base.json")); +} + +TEST(file_with_fragment) { + EXPECT_EQ(round_trip("file:///home/user/schemas/base.json", + "file:///home/user/schemas/other.json#/defs/foo"), + normalised("file:///home/user/schemas/other.json#/defs/foo")); +} + +TEST(file_windows_same_directory) { + EXPECT_EQ(round_trip("file:///C:/Users/user/schemas/base.json", + "file:///C:/Users/user/schemas/other.json"), + normalised("file:///C:/Users/user/schemas/other.json")); +} + +TEST(file_windows_subdirectory) { + EXPECT_EQ(round_trip("file:///C:/Users/user/schemas", + "file:///C:/Users/user/schemas/sub/test.json"), + normalised("file:///C:/Users/user/schemas/sub/test.json")); +} + +TEST(file_windows_parent_directory) { + EXPECT_EQ(round_trip("file:///C:/Users/user/schemas/sub/base.json", + "file:///C:/Users/user/schemas/other.json"), + normalised("file:///C:/Users/user/schemas/other.json")); +} + +TEST(file_windows_different_drive) { + EXPECT_EQ(round_trip("file:///C:/Users/user/schemas/base.json", + "file:///D:/Data/test.json"), + normalised("file:///D:/Data/test.json")); +} + +TEST(file_windows_same_file) { + EXPECT_EQ(round_trip("file:///C:/Users/user/schemas/base.json", + "file:///C:/Users/user/schemas/base.json"), + normalised("file:///C:/Users/user/schemas/base.json")); +} + +TEST(file_windows_with_fragment) { + EXPECT_EQ(round_trip("file:///C:/Users/user/schemas/base.json", + "file:///C:/Users/user/schemas/other.json#/defs/foo"), + normalised("file:///C:/Users/user/schemas/other.json#/defs/foo")); +} + +TEST(same_path_only_query_differs) { + EXPECT_EQ(round_trip("https://www.example.com/foo/bar", + "https://www.example.com/foo/bar?q=1"), + normalised("https://www.example.com/foo/bar?q=1")); +} + +TEST(same_path_only_fragment_differs) { + EXPECT_EQ(round_trip("https://www.example.com/foo/bar", + "https://www.example.com/foo/bar#baz"), + normalised("https://www.example.com/foo/bar#baz")); +} + +TEST(same_path_query_and_fragment_differ) { + EXPECT_EQ(round_trip("https://www.example.com/foo/bar", + "https://www.example.com/foo/bar?q=1#baz"), + normalised("https://www.example.com/foo/bar?q=1#baz")); +} + +TEST(same_path_base_query_target_none) { + EXPECT_EQ(round_trip("https://www.example.com/foo/bar?q=1", + "https://www.example.com/foo/bar"), + normalised("https://www.example.com/foo/bar")); +} + +TEST(same_path_base_query_target_fragment_only) { + EXPECT_EQ(round_trip("https://www.example.com/foo/bar?q=1", + "https://www.example.com/foo/bar#baz"), + normalised("https://www.example.com/foo/bar#baz")); +} + +TEST(same_path_base_query_target_other_query) { + EXPECT_EQ(round_trip("https://www.example.com/foo/bar?q=1", + "https://www.example.com/foo/bar?q=2"), + normalised("https://www.example.com/foo/bar?q=2")); +} + +TEST(same_directory_path_base_query_target_none) { + EXPECT_EQ(round_trip("https://www.example.com/foo/?q=1", + "https://www.example.com/foo/"), + normalised("https://www.example.com/foo/")); +} + +TEST(same_directory_path_base_query_target_fragment) { + EXPECT_EQ(round_trip("https://www.example.com/foo/?q=1", + "https://www.example.com/foo/#baz"), + normalised("https://www.example.com/foo/#baz")); +} + +TEST(same_directory_path_target_query) { + EXPECT_EQ(round_trip("https://www.example.com/foo/?q=1", + "https://www.example.com/foo/?q=2"), + normalised("https://www.example.com/foo/?q=2")); +} + +TEST(authority_no_path_only_fragment_differs) { + EXPECT_EQ( + round_trip("https://www.example.com", "https://www.example.com#baz"), + normalised("https://www.example.com#baz")); +} + +TEST(authority_no_path_only_query_differs) { + EXPECT_EQ( + round_trip("https://www.example.com?q=1", "https://www.example.com?q=2"), + normalised("https://www.example.com?q=2")); +} + +TEST(authority_no_path_base_query_target_none) { + EXPECT_EQ( + round_trip("https://www.example.com?q=1", "https://www.example.com"), + normalised("https://www.example.com")); +} + +TEST(relative_to_same_path_with_query_in_base) { + EXPECT_EQ(round_trip("schema:foo", "schema:foo?bar=1"), + normalised("schema:foo?bar=1")); +} + +TEST(relative_to_slashless_base_path) { + EXPECT_EQ(round_trip("schema:foo", "schema:bar"), normalised("schema:bar")); +} + +TEST(target_is_directory_of_base) { + EXPECT_EQ(round_trip("https://example.com/test/foo.json", + "https://example.com/test/"), + normalised("https://example.com/test/")); +} + +TEST(target_is_nested_directory_of_base) { + EXPECT_EQ(round_trip("https://example.com/foo/bar/baz.json", + "https://example.com/foo/bar/"), + normalised("https://example.com/foo/bar/")); +} + +TEST(target_is_directory_of_base_with_query) { + EXPECT_EQ(round_trip("https://example.com/test/foo.json", + "https://example.com/test/?q=1"), + normalised("https://example.com/test/?q=1")); +} + +TEST(target_is_directory_of_base_with_fragment) { + EXPECT_EQ(round_trip("https://example.com/test/foo.json", + "https://example.com/test/#baz"), + normalised("https://example.com/test/#baz")); +} + +TEST(file_target_is_directory_of_base) { + EXPECT_EQ(round_trip("file:///home/user/schemas/base.json", + "file:///home/user/schemas/"), + normalised("file:///home/user/schemas/")); +} + +TEST(target_is_root_of_base_without_path) { + EXPECT_EQ(round_trip("https://example.com", "https://example.com/"), + normalised("https://example.com/")); +} + +TEST(target_with_empty_first_segment_of_base_without_path) { + EXPECT_EQ(round_trip("https://example.com", "https://example.com//foo"), + normalised("https://example.com//foo")); +} + +TEST(target_with_empty_first_segment_of_root_base) { + EXPECT_EQ(round_trip("https://example.com/foo", "https://example.com//bar"), + normalised("https://example.com//bar")); +} + +TEST(target_with_empty_segment_below_base) { + EXPECT_EQ( + round_trip("https://example.com/foo", "https://example.com/foo//bar"), + normalised("https://example.com/foo//bar")); +} + +TEST(target_with_empty_first_segment_from_nested_base) { + EXPECT_EQ(round_trip("https://example.com/a/b", "https://example.com//bar"), + normalised("https://example.com//bar")); +} + +TEST(target_with_empty_segment_below_directory_base) { + EXPECT_EQ( + round_trip("https://example.com/foo/", "https://example.com/foo//bar"), + normalised("https://example.com/foo//bar")); +} + +TEST(target_with_empty_segment_below_nested_directory_base) { + EXPECT_EQ(round_trip("https://example.com/a/b/c.json", + "https://example.com/a/b//d"), + normalised("https://example.com/a/b//d")); +} + +TEST(target_is_absolute_path_of_base_without_authority) { + EXPECT_EQ(round_trip("schema:", "schema:/foo"), normalised("schema:/foo")); +} + +TEST(sibling_with_colon_in_first_segment) { + EXPECT_EQ(round_trip("file:///C:/Users/user/schemas/base.json", + "file:///C:/Users/user/schemas/D:foo.json"), + normalised("file:///C:/Users/user/schemas/D:foo.json")); +} + +TEST(descendant_of_base_names_the_base_segment) { + EXPECT_EQ( + round_trip("https://example.com/foo", "https://example.com/foo/bar"), + normalised("https://example.com/foo/bar")); +} + +TEST(base_with_fragment) { + EXPECT_EQ( + round_trip("https://example.com/a/b#frag", "https://example.com/a/c"), + normalised("https://example.com/a/c")); +} + +TEST(base_with_query) { + EXPECT_EQ( + round_trip("https://example.com/a/b?q=1", "https://example.com/a/c"), + normalised("https://example.com/a/c")); +} + +TEST(userinfo) { + EXPECT_EQ(round_trip("https://user:pass@example.com/a/b", + "https://user:pass@example.com/a/c"), + normalised("https://user:pass@example.com/a/c")); +} + +TEST(non_default_port) { + EXPECT_EQ(round_trip("https://example.com:8443/a/b", + "https://example.com:8443/a/c"), + normalised("https://example.com:8443/a/c")); +} + +TEST(percent_encoded_space) { + EXPECT_EQ( + round_trip("https://example.com/a/b", "https://example.com/a/c%20d"), + normalised("https://example.com/a/c%20d")); +} + +TEST(percent_encoded_unreserved) { + EXPECT_EQ( + round_trip("https://example.com/a/b", "https://example.com/a/c%41d"), + normalised("https://example.com/a/c%41d")); +} + +TEST(percent_encoded_slash) { + EXPECT_EQ( + round_trip("https://example.com/a/b", "https://example.com/a/c%2Fd"), + normalised("https://example.com/a/c%2Fd")); +} + +TEST(literal_dotdot_prefix_segment) { + EXPECT_EQ( + round_trip("https://example.com/a/b", "https://example.com/a/..foo"), + normalised("https://example.com/a/..foo")); +} + +TEST(literal_dotdot_suffix_segment) { + EXPECT_EQ( + round_trip("https://example.com/a/b", "https://example.com/a/foo.."), + normalised("https://example.com/a/foo..")); +} + +TEST(literal_dot_prefix_segment) { + EXPECT_EQ(round_trip("https://example.com/a/b", "https://example.com/a/.foo"), + normalised("https://example.com/a/.foo")); +} + +TEST(target_without_path) { + EXPECT_EQ(round_trip("https://example.com/a/b", "https://example.com"), + normalised("https://example.com")); +} + +TEST(identical_without_path) { + EXPECT_EQ(round_trip("https://example.com", "https://example.com"), + normalised("https://example.com")); +} + +TEST(foreign_authority) { + EXPECT_EQ(round_trip("https://example.com/a/b", "https://other.com/x"), + normalised("https://other.com/x")); +} + +TEST(slashless_base) { + EXPECT_EQ(round_trip("schema:foo", "schema:bar"), normalised("schema:bar")); +} + +TEST(deep_target_from_root_base) { + EXPECT_EQ(round_trip("https://example.com/", "https://example.com/a/b/c"), + normalised("https://example.com/a/b/c")); +} + +TEST(parent_of_base_directory) { + EXPECT_EQ(round_trip("https://example.com/a/", "https://example.com/"), + normalised("https://example.com/")); +} + +TEST(query_and_fragment) { + EXPECT_EQ( + round_trip("https://example.com/a/b", "https://example.com/a/b?q=1#f"), + normalised("https://example.com/a/b?q=1#f")); +} + +TEST(windows_drive_sibling) { + EXPECT_EQ(round_trip("file:///C:/x/y.json", "file:///C:/x/D:foo.json"), + normalised("file:///C:/x/D:foo.json")); +} + +TEST(target_with_trailing_slash_at_depth) { + EXPECT_EQ( + round_trip("https://example.com/a/b/c", "https://example.com/a/b/d/"), + normalised("https://example.com/a/b/d/")); +} + +TEST(target_is_base_with_trailing_slash) { + EXPECT_EQ(round_trip("https://example.com/a/b", "https://example.com/a/b/"), + normalised("https://example.com/a/b/")); +} + +TEST(deep_descendant) { + EXPECT_EQ( + round_trip("https://example.com/a/b", "https://example.com/a/b/c/d"), + normalised("https://example.com/a/b/c/d")); +} + +TEST(descendant_with_query_and_fragment) { + EXPECT_EQ( + round_trip("https://example.com/a/b", "https://example.com/a/b/c?q=1#f"), + normalised("https://example.com/a/b/c?q=1#f")); +} + +TEST(base_last_segment_has_literal_dots) { + EXPECT_EQ( + round_trip("https://example.com/a/b..", "https://example.com/a/b../c"), + normalised("https://example.com/a/b../c")); +} + +TEST(windows_descendant) { + EXPECT_EQ(round_trip("file:///C:/x", "file:///C:/x/y.json"), + normalised("file:///C:/x/y.json")); +} + +TEST(colon_in_base_last_segment_descendant) { + EXPECT_EQ( + round_trip("https://example.com/D:foo", "https://example.com/D:foo/bar"), + normalised("https://example.com/D:foo/bar")); +} + +TEST(descendant_with_empty_segment) { + EXPECT_EQ(round_trip("https://example.com/a/b", "https://example.com/a/b//c"), + normalised("https://example.com/a/b//c")); +} + +TEST(descendant_of_single_segment_base) { + EXPECT_EQ(round_trip("https://example.com/a", "https://example.com/a/b"), + normalised("https://example.com/a/b")); +} + +TEST(iri_preserves_ucschar) { + const auto base{sourcemeta::core::URI::from_iri("https://example.com/dir/")}; + auto target{ + sourcemeta::core::URI::from_iri("https://example.com/dir/caf\xC3\xA9")}; + target.relative_to(base); + auto resolved{sourcemeta::core::URI::from_iri(target.recompose())}; + resolved.resolve_from(base); + EXPECT_EQ(resolved.recompose(), "https://example.com/dir/caf\xC3\xA9"); +} From 96d1f2a30a9daeb4b73c80ad340894a067ad4ab3 Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Sun, 9 Aug 2026 15:08:49 -0300 Subject: [PATCH 2/8] More Signed-off-by: Juan Cruz Viotti --- src/core/uri/resolution.cc | 13 ++- test/uri/uri_rebase_test.cc | 32 ++++++++ test/uri/uri_relative_to_test.cc | 7 ++ .../uri/uri_relativization_round_trip_test.cc | 80 +++++++++++++++++++ test/uri/uri_resolve_from_test.cc | 14 ++++ 5 files changed, 145 insertions(+), 1 deletion(-) diff --git a/src/core/uri/resolution.cc b/src/core/uri/resolution.cc index 274f58d874..9e6874321a 100644 --- a/src/core/uri/resolution.cc +++ b/src/core/uri/resolution.cc @@ -98,6 +98,10 @@ auto URI::resolve_from(const URI &base) -> URI & { this->userinfo_ = base.userinfo_; this->host_ = base.host_; this->port_ = base.port_; + // RFC 3986 Section 5.2.2 inherits the whole authority, and whether the host + // is an IP literal is part of it, as Section 3.2.2 only writes the enclosing + // brackets for that form + this->ip_literal_ = base.ip_literal_; // Reference has empty path if (!this->path_.has_value() || this->path_.value().empty()) { @@ -261,9 +265,14 @@ auto URI::relative_to(const URI &base) -> URI & { // Result should be "../bundling/bar" // Note: We don't make URIs relative if the target is just a shallow path // like "/foo" (only one level deep) as that's not meaningfully navigable + // RFC 3986 Section 5.2.3 appends the reference to the base path "excluding + // any characters after the right-most "/" in the base URI path, or excluding + // the entire base URI path if it does not contain any "/" characters", so a + // base path without a slash anchors nothing and no suffix of it can be + // stripped to form a reference const auto base_parent = base_last_slash != std::string::npos ? base_path.substr(0, base_last_slash + 1) - : base_path; + : std::string{}; std::string relative_path; std::string current_base_parent{base_parent}; @@ -382,6 +391,7 @@ auto URI::rebase(const URI &base, const URI &new_base) -> URI & { this->userinfo_ = new_base.userinfo_; this->host_ = new_base.host_; this->port_ = new_base.port_; + this->ip_literal_ = new_base.ip_literal_; // The new components come from the new base, so the result is an IRI if the // new base is one this->iri_ = this->iri_ || new_base.iri_; @@ -408,6 +418,7 @@ auto URI::rebase(const URI &base, URI &&new_base) -> URI & { this->userinfo_ = std::move(new_base.userinfo_); this->host_ = std::move(new_base.host_); this->port_ = new_base.port_; + this->ip_literal_ = new_base.ip_literal_; // The new components come from the new base, so the result is an IRI if the // new base is one this->iri_ = this->iri_ || new_base.iri_; diff --git a/test/uri/uri_rebase_test.cc b/test/uri/uri_rebase_test.cc index 9ec5305bc0..cf0320f55e 100644 --- a/test/uri/uri_rebase_test.cc +++ b/test/uri/uri_rebase_test.cc @@ -96,3 +96,35 @@ TEST(different_host_is_left_intact) { uri.rebase(base, new_base); EXPECT_EQ(uri.recompose(), "https://other.com/foo/bar"); } + +TEST(identical_uri_keeps_its_query) { + sourcemeta::core::URI uri{"https://example.com/foo?q=1"}; + const sourcemeta::core::URI base{"https://example.com/foo?q=1"}; + const sourcemeta::core::URI new_base{"/qux"}; + uri.rebase(base, new_base); + EXPECT_EQ(uri.recompose(), "/qux?q=1"); +} + +TEST(identical_uri_keeps_its_fragment) { + sourcemeta::core::URI uri{"https://example.com/foo#frag"}; + const sourcemeta::core::URI base{"https://example.com/foo#frag"}; + const sourcemeta::core::URI new_base{"/qux"}; + uri.rebase(base, new_base); + EXPECT_EQ(uri.recompose(), "/qux#frag"); +} + +TEST(relative_uri_is_left_intact) { + sourcemeta::core::URI uri{"foo/bar"}; + const sourcemeta::core::URI base{"foo"}; + const sourcemeta::core::URI new_base{"/qux"}; + uri.rebase(base, new_base); + EXPECT_EQ(uri.recompose(), "foo/bar"); +} + +TEST(ipv6_new_base_keeps_its_brackets) { + sourcemeta::core::URI uri{"https://example.com/foo/bar"}; + const sourcemeta::core::URI base{"https://example.com/foo"}; + const sourcemeta::core::URI new_base{"https://[::1]/qux"}; + uri.rebase(base, new_base); + EXPECT_EQ(uri.recompose(), "https://[::1]/qux/bar"); +} diff --git a/test/uri/uri_relative_to_test.cc b/test/uri/uri_relative_to_test.cc index 506187ffcf..a46bd0e1e3 100644 --- a/test/uri/uri_relative_to_test.cc +++ b/test/uri/uri_relative_to_test.cc @@ -591,3 +591,10 @@ TEST(descendant_of_base_with_trailing_slash_resolves_back) { resolved.resolve_from(base); EXPECT_EQ(resolved.recompose(), "https://example.com/foo/bar"); } + +TEST(slashless_base_with_descendant_target) { + const sourcemeta::core::URI base{"schema:foo"}; + sourcemeta::core::URI uri{"schema:foo/bar"}; + uri.relative_to(base); + EXPECT_EQ(uri.recompose(), "schema:foo/bar"); +} diff --git a/test/uri/uri_relativization_round_trip_test.cc b/test/uri/uri_relativization_round_trip_test.cc index ddaea7bc1e..ba8e87ca26 100644 --- a/test/uri/uri_relativization_round_trip_test.cc +++ b/test/uri/uri_relativization_round_trip_test.cc @@ -567,3 +567,83 @@ TEST(iri_preserves_ucschar) { resolved.resolve_from(base); EXPECT_EQ(resolved.recompose(), "https://example.com/dir/caf\xC3\xA9"); } + +TEST(slashless_base_with_descendant_target) { + EXPECT_EQ(round_trip("schema:foo", "schema:foo/bar"), + normalised("schema:foo/bar")); +} + +TEST(ipv6_host) { + EXPECT_EQ(round_trip("https://[::1]/a/b", "https://[::1]/a/c"), + normalised("https://[::1]/a/c")); +} + +TEST(parent_of_nested_directory_base) { + EXPECT_EQ(round_trip("https://example.com/a/b/", "https://example.com/a/"), + normalised("https://example.com/a/")); +} + +TEST(ancestor_two_levels_up) { + EXPECT_EQ(round_trip("https://example.com/a/b/c", "https://example.com/a"), + normalised("https://example.com/a")); +} + +TEST(directory_base_with_target_of_the_same_name) { + EXPECT_EQ(round_trip("https://example.com/a/", "https://example.com/a"), + normalised("https://example.com/a")); +} + +TEST(file_base_with_directory_target_of_the_same_name) { + EXPECT_EQ(round_trip("https://example.com/a", "https://example.com/a/"), + normalised("https://example.com/a/")); +} + +TEST(windows_drive_root_from_nested_base) { + EXPECT_EQ(round_trip("file:///C:/x/y/", "file:///C:/"), + normalised("file:///C:/")); +} + +TEST(identical_without_path_but_with_query) { + EXPECT_EQ(round_trip("https://example.com?q=1", "https://example.com?q=1"), + normalised("https://example.com?q=1")); +} + +TEST(ancestor_three_levels_up) { + EXPECT_EQ(round_trip("https://example.com/a/b/c/d", "https://example.com/a"), + normalised("https://example.com/a")); +} + +TEST(mailto_slashless_base) { + EXPECT_EQ(round_trip("mailto:foo", "mailto:bar"), normalised("mailto:bar")); +} + +TEST(target_with_empty_fragment) { + EXPECT_EQ(round_trip("https://example.com/a/b", "https://example.com/a/b#"), + normalised("https://example.com/a/b#")); +} + +TEST(target_with_empty_query) { + EXPECT_EQ(round_trip("https://example.com/a/b", "https://example.com/a/b?"), + normalised("https://example.com/a/b?")); +} + +TEST(sibling_carrying_the_same_query) { + EXPECT_EQ( + round_trip("https://example.com/a/b?x=1", "https://example.com/a/c?x=1"), + normalised("https://example.com/a/c?x=1")); +} + +TEST(root_base_with_target_without_path) { + EXPECT_EQ(round_trip("https://example.com/", "https://example.com"), + normalised("https://example.com")); +} + +TEST(descendant_of_a_base_with_an_empty_first_segment) { + EXPECT_EQ(round_trip("https://example.com//a", "https://example.com//a/b"), + normalised("https://example.com//a/b")); +} + +TEST(target_gains_an_empty_first_segment) { + EXPECT_EQ(round_trip("https://example.com/a", "https://example.com//a"), + normalised("https://example.com//a")); +} diff --git a/test/uri/uri_resolve_from_test.cc b/test/uri/uri_resolve_from_test.cc index e81af8aabc..2f0cd78a5f 100644 --- a/test/uri/uri_resolve_from_test.cc +++ b/test/uri/uri_resolve_from_test.cc @@ -489,3 +489,17 @@ TEST(iri_reference_against_plain_uri_base) { EXPECT_EQ(reference.recompose(), "https://example.com/dir/caf\xC3\xA9"); EXPECT_TRUE(reference.is_internationalized()); } + +TEST(ipv6_base_authority_is_inherited) { + const sourcemeta::core::URI base{"https://[::1]/a/b"}; + sourcemeta::core::URI reference{"c"}; + reference.resolve_from(base); + EXPECT_EQ(reference.recompose(), "https://[::1]/a/c"); +} + +TEST(ipv6_base_authority_is_inherited_by_a_fragment) { + const sourcemeta::core::URI base{"https://[2001:db8::1]:8443/a"}; + sourcemeta::core::URI reference{"#frag"}; + reference.resolve_from(base); + EXPECT_EQ(reference.recompose(), "https://[2001:db8::1]:8443/a#frag"); +} From 0799653a5e52f13019cb13552c2eaf4afebcb5c4 Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Sun, 9 Aug 2026 15:16:43 -0300 Subject: [PATCH 3/8] More Signed-off-by: Juan Cruz Viotti --- src/core/uri/resolution.cc | 25 +- test/uri/uri_rebase_test.cc | 73 +++++ .../uri/uri_relativization_round_trip_test.cc | 254 ++++++++++++++++++ 3 files changed, 340 insertions(+), 12 deletions(-) diff --git a/src/core/uri/resolution.cc b/src/core/uri/resolution.cc index 9e6874321a..944807af9e 100644 --- a/src/core/uri/resolution.cc +++ b/src/core/uri/resolution.cc @@ -235,15 +235,25 @@ auto URI::relative_to(const URI &base) -> URI & { const auto base_last_slash = base_path.rfind('/'); const auto this_last_slash = this_path.rfind('/'); + // RFC 3986 Section 5.2.3 merges against the base path with everything after + // its right-most slash excluded, and Section 5.2.2 removes dot segments only + // once that merge has happened, so what a reference is really measured + // against is that prefix after normalisation. Normalising the whole base + // path instead would be wrong, as a dot segment sitting after the last slash + // is dropped by the merge rather than applied + const auto base_anchor = + base_last_slash != std::string::npos + ? remove_dot_segments(base_path.substr(0, base_last_slash + 1)) + : std::string{}; + // Case 2: Check if both paths share the same parent directory (siblings) // This handles: base="/test/bar.json" and this="/test/foo.json" = // "foo.json" if (base_last_slash != std::string::npos && this_last_slash != std::string::npos) { - const auto base_parent = base_path.substr(0, base_last_slash + 1); const auto this_parent = this_path.substr(0, this_last_slash + 1); - if (base_parent == this_parent) { + if (base_anchor == this_parent) { auto relative_path = this_path.substr(this_last_slash + 1); this->scheme_.reset(); @@ -265,17 +275,8 @@ auto URI::relative_to(const URI &base) -> URI & { // Result should be "../bundling/bar" // Note: We don't make URIs relative if the target is just a shallow path // like "/foo" (only one level deep) as that's not meaningfully navigable - // RFC 3986 Section 5.2.3 appends the reference to the base path "excluding - // any characters after the right-most "/" in the base URI path, or excluding - // the entire base URI path if it does not contain any "/" characters", so a - // base path without a slash anchors nothing and no suffix of it can be - // stripped to form a reference - const auto base_parent = base_last_slash != std::string::npos - ? base_path.substr(0, base_last_slash + 1) - : std::string{}; - std::string relative_path; - std::string current_base_parent{base_parent}; + std::string current_base_parent{base_anchor}; while (!current_base_parent.empty() && current_base_parent != "/") { if (this_path.starts_with(current_base_parent)) { diff --git a/test/uri/uri_rebase_test.cc b/test/uri/uri_rebase_test.cc index cf0320f55e..fd1aeb7955 100644 --- a/test/uri/uri_rebase_test.cc +++ b/test/uri/uri_rebase_test.cc @@ -128,3 +128,76 @@ TEST(ipv6_new_base_keeps_its_brackets) { uri.rebase(base, new_base); EXPECT_EQ(uri.recompose(), "https://[::1]/qux/bar"); } + +TEST(rebasing_back_to_the_original_base_restores_the_uri) { + sourcemeta::core::URI uri{"https://a.com/base/x/y"}; + uri.rebase(sourcemeta::core::URI{"https://a.com/base"}, + sourcemeta::core::URI{"https://b.com/new"}); + EXPECT_EQ(uri.recompose(), "https://b.com/new/x/y"); + uri.rebase(sourcemeta::core::URI{"https://b.com/new"}, + sourcemeta::core::URI{"https://a.com/base"}); + EXPECT_EQ(uri.recompose(), "https://a.com/base/x/y"); +} + +TEST(rebasing_onto_the_same_base_is_a_no_op) { + sourcemeta::core::URI uri{"https://a.com/base/x"}; + uri.rebase(sourcemeta::core::URI{"https://a.com/base"}, + sourcemeta::core::URI{"https://a.com/base"}); + EXPECT_EQ(uri.recompose(), "https://a.com/base/x"); +} + +TEST(dot_segments_in_the_uri_are_resolved) { + sourcemeta::core::URI uri{"https://a.com/base/./x"}; + uri.rebase(sourcemeta::core::URI{"https://a.com/base"}, + sourcemeta::core::URI{"/new"}); + EXPECT_EQ(uri.recompose(), "/new/x"); +} + +TEST(a_uri_escaping_the_base_is_left_intact) { + sourcemeta::core::URI uri{"https://a.com/base/../etc"}; + uri.rebase(sourcemeta::core::URI{"https://a.com/base"}, + sourcemeta::core::URI{"/new"}); + EXPECT_EQ(uri.recompose(), "https://a.com/base/../etc"); +} + +TEST(dot_segments_in_the_base_are_resolved) { + sourcemeta::core::URI uri{"https://a.com/base/x"}; + uri.rebase(sourcemeta::core::URI{"https://a.com/base/."}, + sourcemeta::core::URI{"/new"}); + EXPECT_EQ(uri.recompose(), "/new/x"); +} + +TEST(a_query_on_the_new_base_is_not_carried_over) { + sourcemeta::core::URI uri{"https://a.com/base/x"}; + uri.rebase(sourcemeta::core::URI{"https://a.com/base"}, + sourcemeta::core::URI{"/new?q=1"}); + EXPECT_EQ(uri.recompose(), "/new/x"); +} + +TEST(an_empty_new_base_yields_the_suffix_alone) { + sourcemeta::core::URI uri{"https://a.com/base/x"}; + uri.rebase(sourcemeta::core::URI{"https://a.com/base"}, + sourcemeta::core::URI{""}); + EXPECT_EQ(uri.recompose(), "x"); +} + +TEST(a_new_base_with_a_trailing_slash_does_not_double_it) { + sourcemeta::core::URI uri{"https://a.com/base/x"}; + uri.rebase(sourcemeta::core::URI{"https://a.com/base"}, + sourcemeta::core::URI{"/new/"}); + EXPECT_EQ(uri.recompose(), "/new/x"); +} + +TEST(a_uri_equal_to_the_base_keeps_the_new_base_trailing_slash) { + sourcemeta::core::URI uri{"https://a.com/base"}; + uri.rebase(sourcemeta::core::URI{"https://a.com/base"}, + sourcemeta::core::URI{"/new/"}); + EXPECT_EQ(uri.recompose(), "/new/"); +} + +TEST(a_base_with_a_trailing_slash_matches_the_same_suffix) { + sourcemeta::core::URI uri{"https://a.com/base/x"}; + uri.rebase(sourcemeta::core::URI{"https://a.com/base/"}, + sourcemeta::core::URI{"/new"}); + EXPECT_EQ(uri.recompose(), "/new/x"); +} diff --git a/test/uri/uri_relativization_round_trip_test.cc b/test/uri/uri_relativization_round_trip_test.cc index ba8e87ca26..2b89379a28 100644 --- a/test/uri/uri_relativization_round_trip_test.cc +++ b/test/uri/uri_relativization_round_trip_test.cc @@ -37,6 +37,18 @@ static auto normalised(const std::string &input) -> std::string { return sourcemeta::core::URI{input}.recompose(); } +// RFC 3986 is ASCII only, so a non-ASCII input has to travel the RFC 3987 path +// from parsing through to recomposition for the equation to mean anything +static auto round_trip_iri(const std::string &base_string, + const std::string &target_string) -> std::string { + const auto base{sourcemeta::core::URI::from_iri(base_string)}; + auto target{sourcemeta::core::URI::from_iri(target_string)}; + target.relative_to(base); + auto resolved{sourcemeta::core::URI::from_iri(target.recompose())}; + resolved.resolve_from(base); + return resolved.recompose(); +} + TEST(absolute_absolute_base_true_1) { EXPECT_EQ( round_trip("https://www.example.com", "https://www.example.com/foo"), @@ -647,3 +659,245 @@ TEST(target_gains_an_empty_first_segment) { EXPECT_EQ(round_trip("https://example.com/a", "https://example.com//a"), normalised("https://example.com//a")); } + +TEST(base_with_dot_segment) { + EXPECT_EQ(round_trip("https://example.com/a/./b", "https://example.com/a/c"), + normalised("https://example.com/a/c")); +} + +TEST(base_with_dotdot_segment) { + EXPECT_EQ(round_trip("https://example.com/a/../b", "https://example.com/a/c"), + normalised("https://example.com/a/c")); +} + +TEST(uppercase_scheme_in_base) { + EXPECT_EQ(round_trip("HTTPS://example.com/a/b", "https://example.com/a/c"), + normalised("https://example.com/a/c")); +} + +TEST(uppercase_host_in_base) { + EXPECT_EQ(round_trip("https://EXAMPLE.COM/a/b", "https://example.com/a/c"), + normalised("https://example.com/a/c")); +} + +TEST(explicit_default_port) { + EXPECT_EQ( + round_trip("https://example.com:443/a/b", "https://example.com:443/a/c"), + normalised("https://example.com:443/a/c")); +} + +TEST(base_with_port_and_target_without) { + EXPECT_EQ( + round_trip("https://example.com:443/a/b", "https://example.com/a/c"), + normalised("https://example.com/a/c")); +} + +TEST(deep_ancestor_traversal) { + EXPECT_EQ( + round_trip("https://example.com/a/b/c/d/e/f", "https://example.com/x"), + normalised("https://example.com/x")); +} + +TEST(repeated_empty_segments) { + EXPECT_EQ( + round_trip("https://example.com/a//b//c", "https://example.com/a//b//d"), + normalised("https://example.com/a//b//d")); +} + +TEST(file_with_localhost_authority) { + EXPECT_EQ(round_trip("file://localhost/a/b", "file://localhost/a/c"), + normalised("file://localhost/a/c")); +} + +TEST(file_with_empty_authority) { + EXPECT_EQ(round_trip("file:///a/b", "file:///a/c"), + normalised("file:///a/c")); +} + +TEST(scheme_with_plus_hyphen_and_dot) { + EXPECT_EQ(round_trip("a+b-c.d:/x/y", "a+b-c.d:/x/z"), + normalised("a+b-c.d:/x/z")); +} + +TEST(query_containing_slashes) { + EXPECT_EQ( + round_trip("https://example.com/a/b", "https://example.com/a/c?x=/y/z"), + normalised("https://example.com/a/c?x=/y/z")); +} + +TEST(fragment_containing_slashes) { + EXPECT_EQ( + round_trip("https://example.com/a/b", "https://example.com/a/c#/defs/x"), + normalised("https://example.com/a/c#/defs/x")); +} + +TEST(fragment_containing_question_mark) { + EXPECT_EQ( + round_trip("https://example.com/a/b", "https://example.com/a/c#x?y"), + normalised("https://example.com/a/c#x?y")); +} + +TEST(userinfo_with_colon) { + EXPECT_EQ(round_trip("https://u:p@example.com/a/b", + "https://u:p@example.com/a/b/c"), + normalised("https://u:p@example.com/a/b/c")); +} + +TEST(percent_encoded_userinfo) { + EXPECT_EQ(round_trip("https://u%40x@example.com/a/b", + "https://u%40x@example.com/a/c"), + normalised("https://u%40x@example.com/a/c")); +} + +TEST(path_with_semicolon_parameters) { + EXPECT_EQ( + round_trip("https://example.com/a/b;x=1", "https://example.com/a/c;y=2"), + normalised("https://example.com/a/c;y=2")); +} + +TEST(path_segment_with_at_sign) { + EXPECT_EQ(round_trip("https://example.com/a/b", "https://example.com/a/@c"), + normalised("https://example.com/a/@c")); +} + +TEST(deeply_nested_descendant) { + EXPECT_EQ( + round_trip("https://example.com/a", "https://example.com/a/b/c/d/e/f/g"), + normalised("https://example.com/a/b/c/d/e/f/g")); +} + +TEST(shared_prefix_without_a_segment_boundary) { + EXPECT_EQ( + round_trip("https://example.com/foo", "https://example.com/foobar/baz"), + normalised("https://example.com/foobar/baz")); +} + +// A target path that carries dot segments cannot be named by any relative +// reference, since RFC 3986 Section 5.2.2 removes them on every resolution +// branch. These pin what resolution leaves instead, so the limit stays visible +TEST(encoded_dotdot_segment_is_removed) { + EXPECT_EQ( + round_trip("https://example.com/a/b", "https://example.com/a/%2E%2E/c"), + "https://example.com/c"); +} + +TEST(encoded_dot_segment_is_removed) { + EXPECT_EQ( + round_trip("https://example.com/a/b", "https://example.com/a/%2E/c"), + "https://example.com/a/c"); +} + +TEST(dot_named_final_segment_is_removed) { + EXPECT_EQ(round_trip("https://example.com/a/b", "https://example.com/a/."), + "https://example.com/a/"); +} + +TEST(dotdot_named_final_segment_is_removed) { + EXPECT_EQ(round_trip("https://example.com/a/b", "https://example.com/a/.."), + "https://example.com/"); +} + +TEST(target_of_only_slashes) { + EXPECT_EQ(round_trip("https://example.com/a", "https://example.com///"), + normalised("https://example.com///")); +} + +TEST(base_of_only_slashes) { + EXPECT_EQ(round_trip("https://example.com///", "https://example.com/a"), + normalised("https://example.com/a")); +} + +TEST(root_base_with_empty_first_segment_target) { + EXPECT_EQ(round_trip("https://example.com/", "https://example.com//x"), + normalised("https://example.com//x")); +} + +TEST(urn_siblings) { + EXPECT_EQ(round_trip("urn:example:a", "urn:example:b"), + normalised("urn:example:b")); +} + +TEST(tag_uri_siblings) { + EXPECT_EQ(round_trip("tag:example.com,2024:a", "tag:example.com,2024:b"), + normalised("tag:example.com,2024:b")); +} + +TEST(long_ancestor_chain) { + EXPECT_EQ(round_trip("https://example.com/a/b/c/d/e/f/g/h/i/j/k", + "https://example.com/z"), + normalised("https://example.com/z")); +} + +TEST(long_descendant_chain) { + EXPECT_EQ(round_trip("https://example.com/a", + "https://example.com/a/b/c/d/e/f/g/h/i/j/k"), + normalised("https://example.com/a/b/c/d/e/f/g/h/i/j/k")); +} + +TEST(base_with_dotdot_as_its_last_segment) { + EXPECT_EQ(round_trip("https://example.com/a/b/..", "https://example.com/a/c"), + normalised("https://example.com/a/c")); +} + +TEST(base_with_dot_as_its_last_segment) { + EXPECT_EQ( + round_trip("https://example.com/a/b/.", "https://example.com/a/b/c"), + normalised("https://example.com/a/b/c")); +} + +TEST(base_with_dotdot_beyond_the_root) { + EXPECT_EQ(round_trip("https://example.com/../../a", "https://example.com/b"), + normalised("https://example.com/b")); +} + +TEST(target_is_an_empty_segment_at_the_root) { + EXPECT_EQ(round_trip("https://example.com/a/b", "https://example.com//"), + normalised("https://example.com//")); +} + +TEST(target_is_the_base_parent_exactly) { + EXPECT_EQ(round_trip("https://example.com/a/b/c", "https://example.com/a/b"), + normalised("https://example.com/a/b")); +} + +TEST(single_character_segments) { + EXPECT_EQ( + round_trip("https://example.com/a/b/c", "https://example.com/a/b/d"), + normalised("https://example.com/a/b/d")); +} + +TEST(iri_base_and_target) { + EXPECT_EQ(round_trip_iri("https://example.com/caf\xC3\xA9/a", + "https://example.com/caf\xC3\xA9/b"), + "https://example.com/caf\xC3\xA9/b"); +} + +TEST(iri_query) { + EXPECT_EQ(round_trip_iri("https://example.com/a/b", + "https://example.com/a/c?q=caf\xC3\xA9"), + "https://example.com/a/c?q=caf\xC3\xA9"); +} + +TEST(iri_fragment) { + EXPECT_EQ(round_trip_iri("https://example.com/a/b", + "https://example.com/a/c#caf\xC3\xA9"), + "https://example.com/a/c#caf\xC3\xA9"); +} + +TEST(iri_descendant) { + EXPECT_EQ(round_trip_iri("https://example.com/caf\xC3\xA9", + "https://example.com/caf\xC3\xA9/x"), + "https://example.com/caf\xC3\xA9/x"); +} + +TEST(iri_ancestor) { + EXPECT_EQ(round_trip_iri("https://example.com/caf\xC3\xA9/x/y", + "https://example.com/caf\xC3\xA9"), + "https://example.com/caf\xC3\xA9"); +} + +TEST(iri_host) { + EXPECT_EQ(round_trip_iri("https://caf\xC3\xA9.example/a/b", + "https://caf\xC3\xA9.example/a/c"), + "https://caf\xC3\xA9.example/a/c"); +} From 4076d73be673c827c58b270ab365040479862896 Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Sun, 9 Aug 2026 15:20:58 -0300 Subject: [PATCH 4/8] More Signed-off-by: Juan Cruz Viotti --- src/core/uri/resolution.cc | 6 +++-- test/uri/uri_relative_to_test.cc | 14 ++++++++++++ .../uri/uri_relativization_round_trip_test.cc | 22 +++++++++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/core/uri/resolution.cc b/src/core/uri/resolution.cc index 944807af9e..8122215ffe 100644 --- a/src/core/uri/resolution.cc +++ b/src/core/uri/resolution.cc @@ -156,14 +156,16 @@ auto URI::relative_to(const URI &base) -> URI & { // Special case: both URIs are exactly the same if (this->path_ == base.path_ && this->query_ == base.query_ && this->fragment_ == base.fragment_) { - // Clear all components to make it empty relative URI + // Clear every component the base supplies back on resolution, which is + // everything but the fragment. RFC 3986 Section 5.2.2 always takes the + // fragment from the reference, as "T.fragment = R.fragment", so an empty + // reference names the base without one and has to keep it here this->scheme_.reset(); this->userinfo_.reset(); this->host_.reset(); this->port_.reset(); this->path_.reset(); this->query_.reset(); - this->fragment_.reset(); return *this; } diff --git a/test/uri/uri_relative_to_test.cc b/test/uri/uri_relative_to_test.cc index a46bd0e1e3..ff3cae5b0d 100644 --- a/test/uri/uri_relative_to_test.cc +++ b/test/uri/uri_relative_to_test.cc @@ -598,3 +598,17 @@ TEST(slashless_base_with_descendant_target) { uri.relative_to(base); EXPECT_EQ(uri.recompose(), "schema:foo/bar"); } + +TEST(identical_uris_with_a_fragment_keep_it) { + const sourcemeta::core::URI base{"https://example.com/foo#bar"}; + sourcemeta::core::URI uri{"https://example.com/foo#bar"}; + uri.relative_to(base); + EXPECT_EQ(uri.recompose(), "#bar"); +} + +TEST(identical_uris_with_a_query_and_fragment_keep_the_fragment) { + const sourcemeta::core::URI base{"https://example.com/foo?q=1#bar"}; + sourcemeta::core::URI uri{"https://example.com/foo?q=1#bar"}; + uri.relative_to(base); + EXPECT_EQ(uri.recompose(), "#bar"); +} diff --git a/test/uri/uri_relativization_round_trip_test.cc b/test/uri/uri_relativization_round_trip_test.cc index 2b89379a28..d2661f7776 100644 --- a/test/uri/uri_relativization_round_trip_test.cc +++ b/test/uri/uri_relativization_round_trip_test.cc @@ -901,3 +901,25 @@ TEST(iri_host) { "https://caf\xC3\xA9.example/a/c"), "https://caf\xC3\xA9.example/a/c"); } + +TEST(identical_uris_with_a_fragment) { + EXPECT_EQ( + round_trip("https://example.com/foo#bar", "https://example.com/foo#bar"), + normalised("https://example.com/foo#bar")); +} + +TEST(identical_uris_with_a_query_and_fragment) { + EXPECT_EQ(round_trip("https://example.com/foo?q=1#bar", + "https://example.com/foo?q=1#bar"), + normalised("https://example.com/foo?q=1#bar")); +} + +TEST(identical_authority_less_uris_with_a_fragment) { + EXPECT_EQ(round_trip("schema:foo#bar", "schema:foo#bar"), + normalised("schema:foo#bar")); +} + +TEST(identical_uris_without_a_path_but_with_a_fragment) { + EXPECT_EQ(round_trip("https://example.com#bar", "https://example.com#bar"), + normalised("https://example.com#bar")); +} From 8dc66ab868487441ff6d2160a1616c2caf7bbfcd Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Sun, 9 Aug 2026 15:24:00 -0300 Subject: [PATCH 5/8] More Signed-off-by: Juan Cruz Viotti --- test/uri/uri_resolve_from_test.cc | 38 +++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/test/uri/uri_resolve_from_test.cc b/test/uri/uri_resolve_from_test.cc index 2f0cd78a5f..db6736d9ae 100644 --- a/test/uri/uri_resolve_from_test.cc +++ b/test/uri/uri_resolve_from_test.cc @@ -503,3 +503,41 @@ TEST(ipv6_base_authority_is_inherited_by_a_fragment) { reference.resolve_from(base); EXPECT_EQ(reference.recompose(), "https://[2001:db8::1]:8443/a#frag"); } + +// An empty path segment carries meaning and RFC 3986 Section 5.2.4 never +// removes one, so these survive resolution. Some widely used implementations +// collapse them, which is a deviation rather than a licence to follow +TEST(empty_segment_in_the_reference_is_preserved) { + const sourcemeta::core::URI base{"http://a/b/c/d;p?q"}; + sourcemeta::core::URI reference{"g//h"}; + reference.resolve_from(base); + EXPECT_EQ(reference.recompose(), "http://a/b/c/g//h"); +} + +TEST(dot_segment_before_an_empty_segment_is_preserved) { + const sourcemeta::core::URI base{"http://a/b/c/d;p?q"}; + sourcemeta::core::URI reference{".//g"}; + reference.resolve_from(base); + EXPECT_EQ(reference.recompose(), "http://a/b/c//g"); +} + +TEST(empty_segment_in_the_base_is_preserved) { + const sourcemeta::core::URI base{"http://a//b/c"}; + sourcemeta::core::URI reference{"g"}; + reference.resolve_from(base); + EXPECT_EQ(reference.recompose(), "http://a//b/g"); +} + +TEST(empty_segment_in_the_base_survives_a_parent_step) { + const sourcemeta::core::URI base{"http://a//b/c"}; + sourcemeta::core::URI reference{".."}; + reference.resolve_from(base); + EXPECT_EQ(reference.recompose(), "http://a//"); +} + +TEST(network_path_reference_with_an_empty_authority) { + const sourcemeta::core::URI base{"http://a/b/c/d;p?q"}; + sourcemeta::core::URI reference{"//"}; + reference.resolve_from(base); + EXPECT_EQ(reference.recompose(), "http://"); +} From 6032c11d373e4ffb0542cba279414cc79b55b933 Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Sun, 9 Aug 2026 18:03:58 -0300 Subject: [PATCH 6/8] simpler Signed-off-by: Juan Cruz Viotti --- src/core/uri/include/sourcemeta/core/uri.h | 3 - src/core/uri/resolution.cc | 20 +- test/uri/uri_parse_test.cc | 9 + test/uri/uri_relative_to_test.cc | 31 + .../uri/uri_relativization_round_trip_test.cc | 681 ++++++------------ test/uri/uri_resolve_from_test.cc | 14 + 6 files changed, 301 insertions(+), 457 deletions(-) diff --git a/src/core/uri/include/sourcemeta/core/uri.h b/src/core/uri/include/sourcemeta/core/uri.h index 61d54c7308..29f23a2f20 100644 --- a/src/core/uri/include/sourcemeta/core/uri.h +++ b/src/core/uri/include/sourcemeta/core/uri.h @@ -1127,9 +1127,6 @@ class SOURCEMETA_CORE_URI_EXPORT URI { private: auto parse(std::string_view input) -> void; - [[nodiscard]] auto path_under(const URI &base) const - -> std::optional; - // Exporting symbols that depends on the standard C++ library is considered // safe. // https://learn.microsoft.com/en-us/cpp/error-messages/compiler-warnings/compiler-warning-level-2-c4275?view=msvc-170&redirectedfrom=MSDN diff --git a/src/core/uri/resolution.cc b/src/core/uri/resolution.cc index 8122215ffe..3784390030 100644 --- a/src/core/uri/resolution.cc +++ b/src/core/uri/resolution.cc @@ -367,20 +367,22 @@ auto merge_new_base_path(std::optional &target_path, } } -} // namespace - -auto URI::path_under(const URI &base) const -> std::optional { - if (this->scheme_ != base.scheme_ || this->userinfo_ != base.userinfo_ || - this->host_ != base.host_ || this->port_ != base.port_) { +// The portion of a path that lies below a base, or no value when the URI is +// neither the base nor under it. Component boundaries are respected, so a path +// of "/foobar" does not lie under "/foo" +auto path_under(const URI &uri, const URI &base) -> std::optional { + if (uri.scheme() != base.scheme() || !uri.has_same_authority(base)) { return std::nullopt; } - return URI::strip_path_prefix(this->path_.value_or(""), - base.path_.value_or("")); + return URI::strip_path_prefix(uri.path().value_or(""), + base.path().value_or("")); } +} // namespace + auto URI::rebase(const URI &base, const URI &new_base) -> URI & { - auto suffix{this->path_under(base)}; + auto suffix{path_under(*this, base)}; if (!suffix.has_value()) { return *this; } @@ -407,7 +409,7 @@ auto URI::rebase(const URI &base, const URI &new_base) -> URI & { } auto URI::rebase(const URI &base, URI &&new_base) -> URI & { - auto suffix{this->path_under(base)}; + auto suffix{path_under(*this, base)}; if (!suffix.has_value()) { return *this; } diff --git a/test/uri/uri_parse_test.cc b/test/uri/uri_parse_test.cc index eee98b2f96..bde63c34c2 100644 --- a/test/uri/uri_parse_test.cc +++ b/test/uri/uri_parse_test.cc @@ -969,3 +969,12 @@ TEST(ipvfuture_missing_content_after_dot) { TEST(ipvfuture_invalid_content_character) { EXPECT_FALSE(sourcemeta::core::URI::is_uri("http://[v1.a%]/")); } + +TEST(success_with_percent_encoded_unreserved_is_decoded) { + // RFC 3986 Section 6.2.2.2 equates a percent-encoded unreserved character + // with the character itself, so decoding one can turn what looks like an + // ordinary segment into a dot segment + sourcemeta::core::URI uri{"https://www.example.com/a/%2E%2E/b"}; + EXPECT_EQ(uri.path(), "/a/../b"); + EXPECT_EQ(uri.recompose(), "https://www.example.com/a/../b"); +} diff --git a/test/uri/uri_relative_to_test.cc b/test/uri/uri_relative_to_test.cc index ff3cae5b0d..4bdeaab988 100644 --- a/test/uri/uri_relative_to_test.cc +++ b/test/uri/uri_relative_to_test.cc @@ -612,3 +612,34 @@ TEST(identical_uris_with_a_query_and_fragment_keep_the_fragment) { uri.relative_to(base); EXPECT_EQ(uri.recompose(), "#bar"); } + +// A dot segment in the target survives into the reference, which is as close +// as relativization can get. Resolution removes dot segments on every branch, +// so no reference can name such a target and these do not resolve back to it +TEST(target_with_a_dot_segment_yields_a_dot_reference) { + const sourcemeta::core::URI base{"https://example.com/a/b"}; + sourcemeta::core::URI uri{"https://example.com/a/./c"}; + uri.relative_to(base); + EXPECT_EQ(uri.recompose(), "./c"); +} + +TEST(target_with_a_dotdot_segment_yields_a_dotdot_reference) { + const sourcemeta::core::URI base{"https://example.com/a/b"}; + sourcemeta::core::URI uri{"https://example.com/a/../c"}; + uri.relative_to(base); + EXPECT_EQ(uri.recompose(), "../c"); +} + +TEST(target_ending_in_a_dot_segment) { + const sourcemeta::core::URI base{"https://example.com/a/b"}; + sourcemeta::core::URI uri{"https://example.com/a/."}; + uri.relative_to(base); + EXPECT_EQ(uri.recompose(), "."); +} + +TEST(target_ending_in_a_dotdot_segment) { + const sourcemeta::core::URI base{"https://example.com/a/b"}; + sourcemeta::core::URI uri{"https://example.com/a/.."}; + uri.relative_to(base); + EXPECT_EQ(uri.recompose(), ".."); +} diff --git a/test/uri/uri_relativization_round_trip_test.cc b/test/uri/uri_relativization_round_trip_test.cc index d2661f7776..730bc9bc55 100644 --- a/test/uri/uri_relativization_round_trip_test.cc +++ b/test/uri/uri_relativization_round_trip_test.cc @@ -13,913 +13,704 @@ // asserted in the resolution tests, so it can be treated as the reference // implementation that relativization is measured against rather than as // another moving part. -// -// Both inputs must be absolute, as Section 5.1 requires an absolute base, and -// the target path must be free of dot segments, as Section 5.2.2 removes those -// on every resolution branch and no reference can reproduce them. Every case in -// the relativization tests that meets those conditions has an entry here. A -// failure means the implementation is wrong, not the expectation. static auto round_trip(const std::string &base_string, const std::string &target_string) -> std::string { const sourcemeta::core::URI base{base_string}; sourcemeta::core::URI target{target_string}; + // RFC 3986 Section 5.1 resolves against an absolute base, and a relative + // target is not something the equation can reproduce + EXPECT_TRUE(base.is_absolute()); + EXPECT_TRUE(target.is_absolute()); target.relative_to(base); sourcemeta::core::URI resolved{target.recompose()}; resolved.resolve_from(base); return resolved.recompose(); } -// The parser decodes percent-encoded unreserved characters, so comparing -// against the raw target string would fail for reasons unrelated to -// relativization -static auto normalised(const std::string &input) -> std::string { - return sourcemeta::core::URI{input}.recompose(); -} - // RFC 3986 is ASCII only, so a non-ASCII input has to travel the RFC 3987 path // from parsing through to recomposition for the equation to mean anything static auto round_trip_iri(const std::string &base_string, const std::string &target_string) -> std::string { const auto base{sourcemeta::core::URI::from_iri(base_string)}; auto target{sourcemeta::core::URI::from_iri(target_string)}; + EXPECT_TRUE(base.is_absolute()); + EXPECT_TRUE(target.is_absolute()); target.relative_to(base); auto resolved{sourcemeta::core::URI::from_iri(target.recompose())}; resolved.resolve_from(base); return resolved.recompose(); } +// The expected value is the target parsed and recomposed, rather than the +// target string itself, because the parser decodes percent-encoded unreserved +// characters and that has nothing to do with relativization +#define EXPECT_ROUND_TRIP(base, target) \ + EXPECT_EQ(round_trip((base), (target)), \ + sourcemeta::core::URI{(target)}.recompose()); + +#define EXPECT_IRI_ROUND_TRIP(base, target) \ + EXPECT_EQ(round_trip_iri((base), (target)), (target)); + TEST(absolute_absolute_base_true_1) { - EXPECT_EQ( - round_trip("https://www.example.com", "https://www.example.com/foo"), - normalised("https://www.example.com/foo")); + EXPECT_ROUND_TRIP("https://www.example.com", "https://www.example.com/foo"); } TEST(absolute_absolute_base_true_2) { - EXPECT_EQ( - round_trip("https://www.example.com/foo", "https://www.example.com/foo"), - normalised("https://www.example.com/foo")); + EXPECT_ROUND_TRIP("https://www.example.com/foo", + "https://www.example.com/foo"); } TEST(absolute_absolute_base_true_3) { - EXPECT_EQ(round_trip("https://www.example.com/foo", - "https://www.example.com/foo/bar?q=1"), - normalised("https://www.example.com/foo/bar?q=1")); + EXPECT_ROUND_TRIP("https://www.example.com/foo", + "https://www.example.com/foo/bar?q=1"); } TEST(absolute_absolute_base_true_4) { - EXPECT_EQ(round_trip("https://www.example.com/foo", - "https://www.example.com/foo/bar#baz"), - normalised("https://www.example.com/foo/bar#baz")); + EXPECT_ROUND_TRIP("https://www.example.com/foo", + "https://www.example.com/foo/bar#baz"); } TEST(absolute_absolute_base_false_1) { - EXPECT_EQ( - round_trip("https://www.example.com/foo", "http://www.example.com/foo"), - normalised("http://www.example.com/foo")); + EXPECT_ROUND_TRIP("https://www.example.com/foo", + "http://www.example.com/foo"); } TEST(absolute_absolute_base_false_2) { - EXPECT_EQ( - round_trip("https://www.example.com/foo", "https://www.example.com"), - normalised("https://www.example.com")); + EXPECT_ROUND_TRIP("https://www.example.com/foo", "https://www.example.com"); } TEST(absolute_absolute_base_false_3) { - EXPECT_EQ(round_trip("https://www.example.com/foo/bar", - "https://www.example.com/foo"), - normalised("https://www.example.com/foo")); + EXPECT_ROUND_TRIP("https://www.example.com/foo/bar", + "https://www.example.com/foo"); } TEST(absolute_absolute_base_false_4) { - EXPECT_EQ(round_trip("https://foo.com", "https://bar.com"), - normalised("https://bar.com")); + EXPECT_ROUND_TRIP("https://foo.com", "https://bar.com"); } TEST(absolute_absolute_base_false_different_ports) { - EXPECT_EQ(round_trip("http://localhost:8000", - "http://localhost:9000/schemas/test.json"), - normalised("http://localhost:9000/schemas/test.json")); + EXPECT_ROUND_TRIP("http://localhost:8000", + "http://localhost:9000/schemas/test.json"); } TEST(absolute_absolute_base_false_different_userinfo) { - EXPECT_EQ(round_trip("https://alice@example.com/foo", - "https://bob@example.com/foo/bar"), - normalised("https://bob@example.com/foo/bar")); + EXPECT_ROUND_TRIP("https://alice@example.com/foo", + "https://bob@example.com/foo/bar"); } TEST(absolute_absolute_base_false_userinfo_vs_none) { - EXPECT_EQ(round_trip("https://example.com/foo", - "https://alice@example.com/foo/bar"), - normalised("https://alice@example.com/foo/bar")); + EXPECT_ROUND_TRIP("https://example.com/foo", + "https://alice@example.com/foo/bar"); } -TEST(urn_1) { - EXPECT_EQ(round_trip("schema:", "schema:myschema"), - normalised("schema:myschema")); -} +TEST(urn_1) { EXPECT_ROUND_TRIP("schema:", "schema:myschema"); } TEST(absolute_absolute_trailing_slash) { - EXPECT_EQ( - round_trip("https://github.com/apis-json/api-json/blob/develop/spec", - "https://github.com/apis-json/api-json/blob/develop/spec/"), - normalised("https://github.com/apis-json/api-json/blob/develop/spec/")); + EXPECT_ROUND_TRIP("https://github.com/apis-json/api-json/blob/develop/spec", + "https://github.com/apis-json/api-json/blob/develop/spec/"); } TEST(target_is_prefix_of_base_parent) { - EXPECT_EQ( - round_trip("https://example.com/foo/bar/baz", "https://example.com/foo"), - normalised("https://example.com/foo")); + EXPECT_ROUND_TRIP("https://example.com/foo/bar/baz", + "https://example.com/foo"); } TEST(target_is_one_level_up_at_root) { - EXPECT_EQ( - round_trip("https://example.com/foo/bar", "https://example.com/foo"), - normalised("https://example.com/foo")); + EXPECT_ROUND_TRIP("https://example.com/foo/bar", "https://example.com/foo"); } TEST(target_is_root) { - EXPECT_EQ(round_trip("https://example.com/foo/bar", "https://example.com/"), - normalised("https://example.com/")); + EXPECT_ROUND_TRIP("https://example.com/foo/bar", "https://example.com/"); } TEST(base_ends_with_slash) { - EXPECT_EQ( - round_trip("https://example.com/foo/", "https://example.com/foo/bar"), - normalised("https://example.com/foo/bar")); + EXPECT_ROUND_TRIP("https://example.com/foo/", "https://example.com/foo/bar"); } TEST(base_root_only) { - EXPECT_EQ(round_trip("https://example.com/", "https://example.com/foo"), - normalised("https://example.com/foo")); + EXPECT_ROUND_TRIP("https://example.com/", "https://example.com/foo"); } TEST(sibling_paths_same_directory) { - EXPECT_EQ(round_trip("https://example.com/schemas/bar.json", - "https://example.com/schemas/foo.json"), - normalised("https://example.com/schemas/foo.json")); + EXPECT_ROUND_TRIP("https://example.com/schemas/bar.json", + "https://example.com/schemas/foo.json"); } TEST(double_slash_with_trailing_slash) { - EXPECT_EQ(round_trip("https://example.com/slash/", - "https://example.com/slash/file.json"), - normalised("https://example.com/slash/file.json")); + EXPECT_ROUND_TRIP("https://example.com/slash/", + "https://example.com/slash/file.json"); } TEST(different_directories_same_host_needs_dotdot) { - EXPECT_EQ(round_trip("https://example.com/schemas/with-rebase-same-host.json", - "https://example.com/bundling/single"), - normalised("https://example.com/bundling/single")); + EXPECT_ROUND_TRIP("https://example.com/schemas/with-rebase-same-host.json", + "https://example.com/bundling/single"); } TEST(different_directories_same_host_needs_dotdot_2) { - EXPECT_EQ(round_trip("https://example.com/foo/bar/baz.json", - "https://example.com/qux/test.json"), - normalised("https://example.com/qux/test.json")); + EXPECT_ROUND_TRIP("https://example.com/foo/bar/baz.json", + "https://example.com/qux/test.json"); } TEST(different_directories_same_host_needs_dotdot_3) { - EXPECT_EQ(round_trip("https://example.com/a/b/c.json", - "https://example.com/d.json"), - normalised("https://example.com/d.json")); + EXPECT_ROUND_TRIP("https://example.com/a/b/c.json", + "https://example.com/d.json"); } TEST(file_same_directory) { - EXPECT_EQ(round_trip("file:///home/user/schemas/base.json", - "file:///home/user/schemas/other.json"), - normalised("file:///home/user/schemas/other.json")); + EXPECT_ROUND_TRIP("file:///home/user/schemas/base.json", + "file:///home/user/schemas/other.json"); } TEST(file_subdirectory) { - EXPECT_EQ(round_trip("file:///home/user/schemas", - "file:///home/user/schemas/sub/test.json"), - normalised("file:///home/user/schemas/sub/test.json")); + EXPECT_ROUND_TRIP("file:///home/user/schemas", + "file:///home/user/schemas/sub/test.json"); } TEST(file_parent_directory) { - EXPECT_EQ(round_trip("file:///home/user/schemas/sub/base.json", - "file:///home/user/schemas/other.json"), - normalised("file:///home/user/schemas/other.json")); + EXPECT_ROUND_TRIP("file:///home/user/schemas/sub/base.json", + "file:///home/user/schemas/other.json"); } TEST(file_different_root) { - EXPECT_EQ(round_trip("file:///home/user/schemas/base.json", - "file:///var/data/test.json"), - normalised("file:///var/data/test.json")); + EXPECT_ROUND_TRIP("file:///home/user/schemas/base.json", + "file:///var/data/test.json"); } TEST(file_same_file) { - EXPECT_EQ(round_trip("file:///home/user/schemas/base.json", - "file:///home/user/schemas/base.json"), - normalised("file:///home/user/schemas/base.json")); + EXPECT_ROUND_TRIP("file:///home/user/schemas/base.json", + "file:///home/user/schemas/base.json"); } TEST(file_with_fragment) { - EXPECT_EQ(round_trip("file:///home/user/schemas/base.json", - "file:///home/user/schemas/other.json#/defs/foo"), - normalised("file:///home/user/schemas/other.json#/defs/foo")); + EXPECT_ROUND_TRIP("file:///home/user/schemas/base.json", + "file:///home/user/schemas/other.json#/defs/foo"); } TEST(file_windows_same_directory) { - EXPECT_EQ(round_trip("file:///C:/Users/user/schemas/base.json", - "file:///C:/Users/user/schemas/other.json"), - normalised("file:///C:/Users/user/schemas/other.json")); + EXPECT_ROUND_TRIP("file:///C:/Users/user/schemas/base.json", + "file:///C:/Users/user/schemas/other.json"); } TEST(file_windows_subdirectory) { - EXPECT_EQ(round_trip("file:///C:/Users/user/schemas", - "file:///C:/Users/user/schemas/sub/test.json"), - normalised("file:///C:/Users/user/schemas/sub/test.json")); + EXPECT_ROUND_TRIP("file:///C:/Users/user/schemas", + "file:///C:/Users/user/schemas/sub/test.json"); } TEST(file_windows_parent_directory) { - EXPECT_EQ(round_trip("file:///C:/Users/user/schemas/sub/base.json", - "file:///C:/Users/user/schemas/other.json"), - normalised("file:///C:/Users/user/schemas/other.json")); + EXPECT_ROUND_TRIP("file:///C:/Users/user/schemas/sub/base.json", + "file:///C:/Users/user/schemas/other.json"); } TEST(file_windows_different_drive) { - EXPECT_EQ(round_trip("file:///C:/Users/user/schemas/base.json", - "file:///D:/Data/test.json"), - normalised("file:///D:/Data/test.json")); + EXPECT_ROUND_TRIP("file:///C:/Users/user/schemas/base.json", + "file:///D:/Data/test.json"); } TEST(file_windows_same_file) { - EXPECT_EQ(round_trip("file:///C:/Users/user/schemas/base.json", - "file:///C:/Users/user/schemas/base.json"), - normalised("file:///C:/Users/user/schemas/base.json")); + EXPECT_ROUND_TRIP("file:///C:/Users/user/schemas/base.json", + "file:///C:/Users/user/schemas/base.json"); } TEST(file_windows_with_fragment) { - EXPECT_EQ(round_trip("file:///C:/Users/user/schemas/base.json", - "file:///C:/Users/user/schemas/other.json#/defs/foo"), - normalised("file:///C:/Users/user/schemas/other.json#/defs/foo")); + EXPECT_ROUND_TRIP("file:///C:/Users/user/schemas/base.json", + "file:///C:/Users/user/schemas/other.json#/defs/foo"); } TEST(same_path_only_query_differs) { - EXPECT_EQ(round_trip("https://www.example.com/foo/bar", - "https://www.example.com/foo/bar?q=1"), - normalised("https://www.example.com/foo/bar?q=1")); + EXPECT_ROUND_TRIP("https://www.example.com/foo/bar", + "https://www.example.com/foo/bar?q=1"); } TEST(same_path_only_fragment_differs) { - EXPECT_EQ(round_trip("https://www.example.com/foo/bar", - "https://www.example.com/foo/bar#baz"), - normalised("https://www.example.com/foo/bar#baz")); + EXPECT_ROUND_TRIP("https://www.example.com/foo/bar", + "https://www.example.com/foo/bar#baz"); } TEST(same_path_query_and_fragment_differ) { - EXPECT_EQ(round_trip("https://www.example.com/foo/bar", - "https://www.example.com/foo/bar?q=1#baz"), - normalised("https://www.example.com/foo/bar?q=1#baz")); + EXPECT_ROUND_TRIP("https://www.example.com/foo/bar", + "https://www.example.com/foo/bar?q=1#baz"); } TEST(same_path_base_query_target_none) { - EXPECT_EQ(round_trip("https://www.example.com/foo/bar?q=1", - "https://www.example.com/foo/bar"), - normalised("https://www.example.com/foo/bar")); + EXPECT_ROUND_TRIP("https://www.example.com/foo/bar?q=1", + "https://www.example.com/foo/bar"); } TEST(same_path_base_query_target_fragment_only) { - EXPECT_EQ(round_trip("https://www.example.com/foo/bar?q=1", - "https://www.example.com/foo/bar#baz"), - normalised("https://www.example.com/foo/bar#baz")); + EXPECT_ROUND_TRIP("https://www.example.com/foo/bar?q=1", + "https://www.example.com/foo/bar#baz"); } TEST(same_path_base_query_target_other_query) { - EXPECT_EQ(round_trip("https://www.example.com/foo/bar?q=1", - "https://www.example.com/foo/bar?q=2"), - normalised("https://www.example.com/foo/bar?q=2")); + EXPECT_ROUND_TRIP("https://www.example.com/foo/bar?q=1", + "https://www.example.com/foo/bar?q=2"); } TEST(same_directory_path_base_query_target_none) { - EXPECT_EQ(round_trip("https://www.example.com/foo/?q=1", - "https://www.example.com/foo/"), - normalised("https://www.example.com/foo/")); + EXPECT_ROUND_TRIP("https://www.example.com/foo/?q=1", + "https://www.example.com/foo/"); } TEST(same_directory_path_base_query_target_fragment) { - EXPECT_EQ(round_trip("https://www.example.com/foo/?q=1", - "https://www.example.com/foo/#baz"), - normalised("https://www.example.com/foo/#baz")); + EXPECT_ROUND_TRIP("https://www.example.com/foo/?q=1", + "https://www.example.com/foo/#baz"); } TEST(same_directory_path_target_query) { - EXPECT_EQ(round_trip("https://www.example.com/foo/?q=1", - "https://www.example.com/foo/?q=2"), - normalised("https://www.example.com/foo/?q=2")); + EXPECT_ROUND_TRIP("https://www.example.com/foo/?q=1", + "https://www.example.com/foo/?q=2"); } TEST(authority_no_path_only_fragment_differs) { - EXPECT_EQ( - round_trip("https://www.example.com", "https://www.example.com#baz"), - normalised("https://www.example.com#baz")); + EXPECT_ROUND_TRIP("https://www.example.com", "https://www.example.com#baz"); } TEST(authority_no_path_only_query_differs) { - EXPECT_EQ( - round_trip("https://www.example.com?q=1", "https://www.example.com?q=2"), - normalised("https://www.example.com?q=2")); + EXPECT_ROUND_TRIP("https://www.example.com?q=1", + "https://www.example.com?q=2"); } TEST(authority_no_path_base_query_target_none) { - EXPECT_EQ( - round_trip("https://www.example.com?q=1", "https://www.example.com"), - normalised("https://www.example.com")); + EXPECT_ROUND_TRIP("https://www.example.com?q=1", "https://www.example.com"); } TEST(relative_to_same_path_with_query_in_base) { - EXPECT_EQ(round_trip("schema:foo", "schema:foo?bar=1"), - normalised("schema:foo?bar=1")); + EXPECT_ROUND_TRIP("schema:foo", "schema:foo?bar=1"); } TEST(relative_to_slashless_base_path) { - EXPECT_EQ(round_trip("schema:foo", "schema:bar"), normalised("schema:bar")); + EXPECT_ROUND_TRIP("schema:foo", "schema:bar"); } TEST(target_is_directory_of_base) { - EXPECT_EQ(round_trip("https://example.com/test/foo.json", - "https://example.com/test/"), - normalised("https://example.com/test/")); + EXPECT_ROUND_TRIP("https://example.com/test/foo.json", + "https://example.com/test/"); } TEST(target_is_nested_directory_of_base) { - EXPECT_EQ(round_trip("https://example.com/foo/bar/baz.json", - "https://example.com/foo/bar/"), - normalised("https://example.com/foo/bar/")); + EXPECT_ROUND_TRIP("https://example.com/foo/bar/baz.json", + "https://example.com/foo/bar/"); } TEST(target_is_directory_of_base_with_query) { - EXPECT_EQ(round_trip("https://example.com/test/foo.json", - "https://example.com/test/?q=1"), - normalised("https://example.com/test/?q=1")); + EXPECT_ROUND_TRIP("https://example.com/test/foo.json", + "https://example.com/test/?q=1"); } TEST(target_is_directory_of_base_with_fragment) { - EXPECT_EQ(round_trip("https://example.com/test/foo.json", - "https://example.com/test/#baz"), - normalised("https://example.com/test/#baz")); + EXPECT_ROUND_TRIP("https://example.com/test/foo.json", + "https://example.com/test/#baz"); } TEST(file_target_is_directory_of_base) { - EXPECT_EQ(round_trip("file:///home/user/schemas/base.json", - "file:///home/user/schemas/"), - normalised("file:///home/user/schemas/")); + EXPECT_ROUND_TRIP("file:///home/user/schemas/base.json", + "file:///home/user/schemas/"); } TEST(target_is_root_of_base_without_path) { - EXPECT_EQ(round_trip("https://example.com", "https://example.com/"), - normalised("https://example.com/")); + EXPECT_ROUND_TRIP("https://example.com", "https://example.com/"); } TEST(target_with_empty_first_segment_of_base_without_path) { - EXPECT_EQ(round_trip("https://example.com", "https://example.com//foo"), - normalised("https://example.com//foo")); + EXPECT_ROUND_TRIP("https://example.com", "https://example.com//foo"); } TEST(target_with_empty_first_segment_of_root_base) { - EXPECT_EQ(round_trip("https://example.com/foo", "https://example.com//bar"), - normalised("https://example.com//bar")); + EXPECT_ROUND_TRIP("https://example.com/foo", "https://example.com//bar"); } TEST(target_with_empty_segment_below_base) { - EXPECT_EQ( - round_trip("https://example.com/foo", "https://example.com/foo//bar"), - normalised("https://example.com/foo//bar")); + EXPECT_ROUND_TRIP("https://example.com/foo", "https://example.com/foo//bar"); } TEST(target_with_empty_first_segment_from_nested_base) { - EXPECT_EQ(round_trip("https://example.com/a/b", "https://example.com//bar"), - normalised("https://example.com//bar")); + EXPECT_ROUND_TRIP("https://example.com/a/b", "https://example.com//bar"); } TEST(target_with_empty_segment_below_directory_base) { - EXPECT_EQ( - round_trip("https://example.com/foo/", "https://example.com/foo//bar"), - normalised("https://example.com/foo//bar")); + EXPECT_ROUND_TRIP("https://example.com/foo/", "https://example.com/foo//bar"); } TEST(target_with_empty_segment_below_nested_directory_base) { - EXPECT_EQ(round_trip("https://example.com/a/b/c.json", - "https://example.com/a/b//d"), - normalised("https://example.com/a/b//d")); + EXPECT_ROUND_TRIP("https://example.com/a/b/c.json", + "https://example.com/a/b//d"); } TEST(target_is_absolute_path_of_base_without_authority) { - EXPECT_EQ(round_trip("schema:", "schema:/foo"), normalised("schema:/foo")); + EXPECT_ROUND_TRIP("schema:", "schema:/foo"); } TEST(sibling_with_colon_in_first_segment) { - EXPECT_EQ(round_trip("file:///C:/Users/user/schemas/base.json", - "file:///C:/Users/user/schemas/D:foo.json"), - normalised("file:///C:/Users/user/schemas/D:foo.json")); + EXPECT_ROUND_TRIP("file:///C:/Users/user/schemas/base.json", + "file:///C:/Users/user/schemas/D:foo.json"); } TEST(descendant_of_base_names_the_base_segment) { - EXPECT_EQ( - round_trip("https://example.com/foo", "https://example.com/foo/bar"), - normalised("https://example.com/foo/bar")); + EXPECT_ROUND_TRIP("https://example.com/foo", "https://example.com/foo/bar"); } TEST(base_with_fragment) { - EXPECT_EQ( - round_trip("https://example.com/a/b#frag", "https://example.com/a/c"), - normalised("https://example.com/a/c")); + EXPECT_ROUND_TRIP("https://example.com/a/b#frag", "https://example.com/a/c"); } TEST(base_with_query) { - EXPECT_EQ( - round_trip("https://example.com/a/b?q=1", "https://example.com/a/c"), - normalised("https://example.com/a/c")); + EXPECT_ROUND_TRIP("https://example.com/a/b?q=1", "https://example.com/a/c"); } TEST(userinfo) { - EXPECT_EQ(round_trip("https://user:pass@example.com/a/b", - "https://user:pass@example.com/a/c"), - normalised("https://user:pass@example.com/a/c")); + EXPECT_ROUND_TRIP("https://user:pass@example.com/a/b", + "https://user:pass@example.com/a/c"); } TEST(non_default_port) { - EXPECT_EQ(round_trip("https://example.com:8443/a/b", - "https://example.com:8443/a/c"), - normalised("https://example.com:8443/a/c")); + EXPECT_ROUND_TRIP("https://example.com:8443/a/b", + "https://example.com:8443/a/c"); } TEST(percent_encoded_space) { - EXPECT_EQ( - round_trip("https://example.com/a/b", "https://example.com/a/c%20d"), - normalised("https://example.com/a/c%20d")); + EXPECT_ROUND_TRIP("https://example.com/a/b", "https://example.com/a/c%20d"); } TEST(percent_encoded_unreserved) { - EXPECT_EQ( - round_trip("https://example.com/a/b", "https://example.com/a/c%41d"), - normalised("https://example.com/a/c%41d")); + EXPECT_ROUND_TRIP("https://example.com/a/b", "https://example.com/a/c%41d"); } TEST(percent_encoded_slash) { - EXPECT_EQ( - round_trip("https://example.com/a/b", "https://example.com/a/c%2Fd"), - normalised("https://example.com/a/c%2Fd")); + EXPECT_ROUND_TRIP("https://example.com/a/b", "https://example.com/a/c%2Fd"); } TEST(literal_dotdot_prefix_segment) { - EXPECT_EQ( - round_trip("https://example.com/a/b", "https://example.com/a/..foo"), - normalised("https://example.com/a/..foo")); + EXPECT_ROUND_TRIP("https://example.com/a/b", "https://example.com/a/..foo"); } TEST(literal_dotdot_suffix_segment) { - EXPECT_EQ( - round_trip("https://example.com/a/b", "https://example.com/a/foo.."), - normalised("https://example.com/a/foo..")); + EXPECT_ROUND_TRIP("https://example.com/a/b", "https://example.com/a/foo.."); } TEST(literal_dot_prefix_segment) { - EXPECT_EQ(round_trip("https://example.com/a/b", "https://example.com/a/.foo"), - normalised("https://example.com/a/.foo")); + EXPECT_ROUND_TRIP("https://example.com/a/b", "https://example.com/a/.foo"); } TEST(target_without_path) { - EXPECT_EQ(round_trip("https://example.com/a/b", "https://example.com"), - normalised("https://example.com")); + EXPECT_ROUND_TRIP("https://example.com/a/b", "https://example.com"); } TEST(identical_without_path) { - EXPECT_EQ(round_trip("https://example.com", "https://example.com"), - normalised("https://example.com")); + EXPECT_ROUND_TRIP("https://example.com", "https://example.com"); } TEST(foreign_authority) { - EXPECT_EQ(round_trip("https://example.com/a/b", "https://other.com/x"), - normalised("https://other.com/x")); + EXPECT_ROUND_TRIP("https://example.com/a/b", "https://other.com/x"); } -TEST(slashless_base) { - EXPECT_EQ(round_trip("schema:foo", "schema:bar"), normalised("schema:bar")); -} +TEST(slashless_base) { EXPECT_ROUND_TRIP("schema:foo", "schema:bar"); } TEST(deep_target_from_root_base) { - EXPECT_EQ(round_trip("https://example.com/", "https://example.com/a/b/c"), - normalised("https://example.com/a/b/c")); + EXPECT_ROUND_TRIP("https://example.com/", "https://example.com/a/b/c"); } TEST(parent_of_base_directory) { - EXPECT_EQ(round_trip("https://example.com/a/", "https://example.com/"), - normalised("https://example.com/")); + EXPECT_ROUND_TRIP("https://example.com/a/", "https://example.com/"); } TEST(query_and_fragment) { - EXPECT_EQ( - round_trip("https://example.com/a/b", "https://example.com/a/b?q=1#f"), - normalised("https://example.com/a/b?q=1#f")); + EXPECT_ROUND_TRIP("https://example.com/a/b", "https://example.com/a/b?q=1#f"); } TEST(windows_drive_sibling) { - EXPECT_EQ(round_trip("file:///C:/x/y.json", "file:///C:/x/D:foo.json"), - normalised("file:///C:/x/D:foo.json")); + EXPECT_ROUND_TRIP("file:///C:/x/y.json", "file:///C:/x/D:foo.json"); } TEST(target_with_trailing_slash_at_depth) { - EXPECT_EQ( - round_trip("https://example.com/a/b/c", "https://example.com/a/b/d/"), - normalised("https://example.com/a/b/d/")); + EXPECT_ROUND_TRIP("https://example.com/a/b/c", "https://example.com/a/b/d/"); } TEST(target_is_base_with_trailing_slash) { - EXPECT_EQ(round_trip("https://example.com/a/b", "https://example.com/a/b/"), - normalised("https://example.com/a/b/")); + EXPECT_ROUND_TRIP("https://example.com/a/b", "https://example.com/a/b/"); } TEST(deep_descendant) { - EXPECT_EQ( - round_trip("https://example.com/a/b", "https://example.com/a/b/c/d"), - normalised("https://example.com/a/b/c/d")); + EXPECT_ROUND_TRIP("https://example.com/a/b", "https://example.com/a/b/c/d"); } TEST(descendant_with_query_and_fragment) { - EXPECT_EQ( - round_trip("https://example.com/a/b", "https://example.com/a/b/c?q=1#f"), - normalised("https://example.com/a/b/c?q=1#f")); + EXPECT_ROUND_TRIP("https://example.com/a/b", + "https://example.com/a/b/c?q=1#f"); } TEST(base_last_segment_has_literal_dots) { - EXPECT_EQ( - round_trip("https://example.com/a/b..", "https://example.com/a/b../c"), - normalised("https://example.com/a/b../c")); + EXPECT_ROUND_TRIP("https://example.com/a/b..", "https://example.com/a/b../c"); } TEST(windows_descendant) { - EXPECT_EQ(round_trip("file:///C:/x", "file:///C:/x/y.json"), - normalised("file:///C:/x/y.json")); + EXPECT_ROUND_TRIP("file:///C:/x", "file:///C:/x/y.json"); } TEST(colon_in_base_last_segment_descendant) { - EXPECT_EQ( - round_trip("https://example.com/D:foo", "https://example.com/D:foo/bar"), - normalised("https://example.com/D:foo/bar")); + EXPECT_ROUND_TRIP("https://example.com/D:foo", + "https://example.com/D:foo/bar"); } TEST(descendant_with_empty_segment) { - EXPECT_EQ(round_trip("https://example.com/a/b", "https://example.com/a/b//c"), - normalised("https://example.com/a/b//c")); + EXPECT_ROUND_TRIP("https://example.com/a/b", "https://example.com/a/b//c"); } TEST(descendant_of_single_segment_base) { - EXPECT_EQ(round_trip("https://example.com/a", "https://example.com/a/b"), - normalised("https://example.com/a/b")); + EXPECT_ROUND_TRIP("https://example.com/a", "https://example.com/a/b"); } TEST(iri_preserves_ucschar) { - const auto base{sourcemeta::core::URI::from_iri("https://example.com/dir/")}; - auto target{ - sourcemeta::core::URI::from_iri("https://example.com/dir/caf\xC3\xA9")}; - target.relative_to(base); - auto resolved{sourcemeta::core::URI::from_iri(target.recompose())}; - resolved.resolve_from(base); - EXPECT_EQ(resolved.recompose(), "https://example.com/dir/caf\xC3\xA9"); + EXPECT_IRI_ROUND_TRIP("https://example.com/dir/", + "https://example.com/dir/caf\xC3\xA9"); } TEST(slashless_base_with_descendant_target) { - EXPECT_EQ(round_trip("schema:foo", "schema:foo/bar"), - normalised("schema:foo/bar")); + EXPECT_ROUND_TRIP("schema:foo", "schema:foo/bar"); } -TEST(ipv6_host) { - EXPECT_EQ(round_trip("https://[::1]/a/b", "https://[::1]/a/c"), - normalised("https://[::1]/a/c")); -} +TEST(ipv6_host) { EXPECT_ROUND_TRIP("https://[::1]/a/b", "https://[::1]/a/c"); } TEST(parent_of_nested_directory_base) { - EXPECT_EQ(round_trip("https://example.com/a/b/", "https://example.com/a/"), - normalised("https://example.com/a/")); + EXPECT_ROUND_TRIP("https://example.com/a/b/", "https://example.com/a/"); } TEST(ancestor_two_levels_up) { - EXPECT_EQ(round_trip("https://example.com/a/b/c", "https://example.com/a"), - normalised("https://example.com/a")); + EXPECT_ROUND_TRIP("https://example.com/a/b/c", "https://example.com/a"); } TEST(directory_base_with_target_of_the_same_name) { - EXPECT_EQ(round_trip("https://example.com/a/", "https://example.com/a"), - normalised("https://example.com/a")); + EXPECT_ROUND_TRIP("https://example.com/a/", "https://example.com/a"); } TEST(file_base_with_directory_target_of_the_same_name) { - EXPECT_EQ(round_trip("https://example.com/a", "https://example.com/a/"), - normalised("https://example.com/a/")); + EXPECT_ROUND_TRIP("https://example.com/a", "https://example.com/a/"); } TEST(windows_drive_root_from_nested_base) { - EXPECT_EQ(round_trip("file:///C:/x/y/", "file:///C:/"), - normalised("file:///C:/")); + EXPECT_ROUND_TRIP("file:///C:/x/y/", "file:///C:/"); } TEST(identical_without_path_but_with_query) { - EXPECT_EQ(round_trip("https://example.com?q=1", "https://example.com?q=1"), - normalised("https://example.com?q=1")); + EXPECT_ROUND_TRIP("https://example.com?q=1", "https://example.com?q=1"); } TEST(ancestor_three_levels_up) { - EXPECT_EQ(round_trip("https://example.com/a/b/c/d", "https://example.com/a"), - normalised("https://example.com/a")); + EXPECT_ROUND_TRIP("https://example.com/a/b/c/d", "https://example.com/a"); } -TEST(mailto_slashless_base) { - EXPECT_EQ(round_trip("mailto:foo", "mailto:bar"), normalised("mailto:bar")); -} +TEST(mailto_slashless_base) { EXPECT_ROUND_TRIP("mailto:foo", "mailto:bar"); } TEST(target_with_empty_fragment) { - EXPECT_EQ(round_trip("https://example.com/a/b", "https://example.com/a/b#"), - normalised("https://example.com/a/b#")); + EXPECT_ROUND_TRIP("https://example.com/a/b", "https://example.com/a/b#"); } TEST(target_with_empty_query) { - EXPECT_EQ(round_trip("https://example.com/a/b", "https://example.com/a/b?"), - normalised("https://example.com/a/b?")); + EXPECT_ROUND_TRIP("https://example.com/a/b", "https://example.com/a/b?"); } TEST(sibling_carrying_the_same_query) { - EXPECT_EQ( - round_trip("https://example.com/a/b?x=1", "https://example.com/a/c?x=1"), - normalised("https://example.com/a/c?x=1")); + EXPECT_ROUND_TRIP("https://example.com/a/b?x=1", + "https://example.com/a/c?x=1"); } TEST(root_base_with_target_without_path) { - EXPECT_EQ(round_trip("https://example.com/", "https://example.com"), - normalised("https://example.com")); + EXPECT_ROUND_TRIP("https://example.com/", "https://example.com"); } TEST(descendant_of_a_base_with_an_empty_first_segment) { - EXPECT_EQ(round_trip("https://example.com//a", "https://example.com//a/b"), - normalised("https://example.com//a/b")); + EXPECT_ROUND_TRIP("https://example.com//a", "https://example.com//a/b"); } TEST(target_gains_an_empty_first_segment) { - EXPECT_EQ(round_trip("https://example.com/a", "https://example.com//a"), - normalised("https://example.com//a")); + EXPECT_ROUND_TRIP("https://example.com/a", "https://example.com//a"); } TEST(base_with_dot_segment) { - EXPECT_EQ(round_trip("https://example.com/a/./b", "https://example.com/a/c"), - normalised("https://example.com/a/c")); + EXPECT_ROUND_TRIP("https://example.com/a/./b", "https://example.com/a/c"); } TEST(base_with_dotdot_segment) { - EXPECT_EQ(round_trip("https://example.com/a/../b", "https://example.com/a/c"), - normalised("https://example.com/a/c")); + EXPECT_ROUND_TRIP("https://example.com/a/../b", "https://example.com/a/c"); } TEST(uppercase_scheme_in_base) { - EXPECT_EQ(round_trip("HTTPS://example.com/a/b", "https://example.com/a/c"), - normalised("https://example.com/a/c")); + EXPECT_ROUND_TRIP("HTTPS://example.com/a/b", "https://example.com/a/c"); } TEST(uppercase_host_in_base) { - EXPECT_EQ(round_trip("https://EXAMPLE.COM/a/b", "https://example.com/a/c"), - normalised("https://example.com/a/c")); + EXPECT_ROUND_TRIP("https://EXAMPLE.COM/a/b", "https://example.com/a/c"); } TEST(explicit_default_port) { - EXPECT_EQ( - round_trip("https://example.com:443/a/b", "https://example.com:443/a/c"), - normalised("https://example.com:443/a/c")); + EXPECT_ROUND_TRIP("https://example.com:443/a/b", + "https://example.com:443/a/c"); } TEST(base_with_port_and_target_without) { - EXPECT_EQ( - round_trip("https://example.com:443/a/b", "https://example.com/a/c"), - normalised("https://example.com/a/c")); + EXPECT_ROUND_TRIP("https://example.com:443/a/b", "https://example.com/a/c"); } TEST(deep_ancestor_traversal) { - EXPECT_EQ( - round_trip("https://example.com/a/b/c/d/e/f", "https://example.com/x"), - normalised("https://example.com/x")); + EXPECT_ROUND_TRIP("https://example.com/a/b/c/d/e/f", "https://example.com/x"); } TEST(repeated_empty_segments) { - EXPECT_EQ( - round_trip("https://example.com/a//b//c", "https://example.com/a//b//d"), - normalised("https://example.com/a//b//d")); + EXPECT_ROUND_TRIP("https://example.com/a//b//c", + "https://example.com/a//b//d"); } TEST(file_with_localhost_authority) { - EXPECT_EQ(round_trip("file://localhost/a/b", "file://localhost/a/c"), - normalised("file://localhost/a/c")); + EXPECT_ROUND_TRIP("file://localhost/a/b", "file://localhost/a/c"); } TEST(file_with_empty_authority) { - EXPECT_EQ(round_trip("file:///a/b", "file:///a/c"), - normalised("file:///a/c")); + EXPECT_ROUND_TRIP("file:///a/b", "file:///a/c"); } TEST(scheme_with_plus_hyphen_and_dot) { - EXPECT_EQ(round_trip("a+b-c.d:/x/y", "a+b-c.d:/x/z"), - normalised("a+b-c.d:/x/z")); + EXPECT_ROUND_TRIP("a+b-c.d:/x/y", "a+b-c.d:/x/z"); } TEST(query_containing_slashes) { - EXPECT_EQ( - round_trip("https://example.com/a/b", "https://example.com/a/c?x=/y/z"), - normalised("https://example.com/a/c?x=/y/z")); + EXPECT_ROUND_TRIP("https://example.com/a/b", + "https://example.com/a/c?x=/y/z"); } TEST(fragment_containing_slashes) { - EXPECT_EQ( - round_trip("https://example.com/a/b", "https://example.com/a/c#/defs/x"), - normalised("https://example.com/a/c#/defs/x")); + EXPECT_ROUND_TRIP("https://example.com/a/b", + "https://example.com/a/c#/defs/x"); } TEST(fragment_containing_question_mark) { - EXPECT_EQ( - round_trip("https://example.com/a/b", "https://example.com/a/c#x?y"), - normalised("https://example.com/a/c#x?y")); + EXPECT_ROUND_TRIP("https://example.com/a/b", "https://example.com/a/c#x?y"); } TEST(userinfo_with_colon) { - EXPECT_EQ(round_trip("https://u:p@example.com/a/b", - "https://u:p@example.com/a/b/c"), - normalised("https://u:p@example.com/a/b/c")); + EXPECT_ROUND_TRIP("https://u:p@example.com/a/b", + "https://u:p@example.com/a/b/c"); } TEST(percent_encoded_userinfo) { - EXPECT_EQ(round_trip("https://u%40x@example.com/a/b", - "https://u%40x@example.com/a/c"), - normalised("https://u%40x@example.com/a/c")); + EXPECT_ROUND_TRIP("https://u%40x@example.com/a/b", + "https://u%40x@example.com/a/c"); } TEST(path_with_semicolon_parameters) { - EXPECT_EQ( - round_trip("https://example.com/a/b;x=1", "https://example.com/a/c;y=2"), - normalised("https://example.com/a/c;y=2")); + EXPECT_ROUND_TRIP("https://example.com/a/b;x=1", + "https://example.com/a/c;y=2"); } TEST(path_segment_with_at_sign) { - EXPECT_EQ(round_trip("https://example.com/a/b", "https://example.com/a/@c"), - normalised("https://example.com/a/@c")); + EXPECT_ROUND_TRIP("https://example.com/a/b", "https://example.com/a/@c"); } TEST(deeply_nested_descendant) { - EXPECT_EQ( - round_trip("https://example.com/a", "https://example.com/a/b/c/d/e/f/g"), - normalised("https://example.com/a/b/c/d/e/f/g")); + EXPECT_ROUND_TRIP("https://example.com/a", + "https://example.com/a/b/c/d/e/f/g"); } TEST(shared_prefix_without_a_segment_boundary) { - EXPECT_EQ( - round_trip("https://example.com/foo", "https://example.com/foobar/baz"), - normalised("https://example.com/foobar/baz")); -} - -// A target path that carries dot segments cannot be named by any relative -// reference, since RFC 3986 Section 5.2.2 removes them on every resolution -// branch. These pin what resolution leaves instead, so the limit stays visible -TEST(encoded_dotdot_segment_is_removed) { - EXPECT_EQ( - round_trip("https://example.com/a/b", "https://example.com/a/%2E%2E/c"), - "https://example.com/c"); -} - -TEST(encoded_dot_segment_is_removed) { - EXPECT_EQ( - round_trip("https://example.com/a/b", "https://example.com/a/%2E/c"), - "https://example.com/a/c"); -} - -TEST(dot_named_final_segment_is_removed) { - EXPECT_EQ(round_trip("https://example.com/a/b", "https://example.com/a/."), - "https://example.com/a/"); -} - -TEST(dotdot_named_final_segment_is_removed) { - EXPECT_EQ(round_trip("https://example.com/a/b", "https://example.com/a/.."), - "https://example.com/"); + EXPECT_ROUND_TRIP("https://example.com/foo", + "https://example.com/foobar/baz"); } TEST(target_of_only_slashes) { - EXPECT_EQ(round_trip("https://example.com/a", "https://example.com///"), - normalised("https://example.com///")); + EXPECT_ROUND_TRIP("https://example.com/a", "https://example.com///"); } TEST(base_of_only_slashes) { - EXPECT_EQ(round_trip("https://example.com///", "https://example.com/a"), - normalised("https://example.com/a")); + EXPECT_ROUND_TRIP("https://example.com///", "https://example.com/a"); } TEST(root_base_with_empty_first_segment_target) { - EXPECT_EQ(round_trip("https://example.com/", "https://example.com//x"), - normalised("https://example.com//x")); + EXPECT_ROUND_TRIP("https://example.com/", "https://example.com//x"); } -TEST(urn_siblings) { - EXPECT_EQ(round_trip("urn:example:a", "urn:example:b"), - normalised("urn:example:b")); -} +TEST(urn_siblings) { EXPECT_ROUND_TRIP("urn:example:a", "urn:example:b"); } TEST(tag_uri_siblings) { - EXPECT_EQ(round_trip("tag:example.com,2024:a", "tag:example.com,2024:b"), - normalised("tag:example.com,2024:b")); + EXPECT_ROUND_TRIP("tag:example.com,2024:a", "tag:example.com,2024:b"); } TEST(long_ancestor_chain) { - EXPECT_EQ(round_trip("https://example.com/a/b/c/d/e/f/g/h/i/j/k", - "https://example.com/z"), - normalised("https://example.com/z")); + EXPECT_ROUND_TRIP("https://example.com/a/b/c/d/e/f/g/h/i/j/k", + "https://example.com/z"); } TEST(long_descendant_chain) { - EXPECT_EQ(round_trip("https://example.com/a", - "https://example.com/a/b/c/d/e/f/g/h/i/j/k"), - normalised("https://example.com/a/b/c/d/e/f/g/h/i/j/k")); + EXPECT_ROUND_TRIP("https://example.com/a", + "https://example.com/a/b/c/d/e/f/g/h/i/j/k"); } TEST(base_with_dotdot_as_its_last_segment) { - EXPECT_EQ(round_trip("https://example.com/a/b/..", "https://example.com/a/c"), - normalised("https://example.com/a/c")); + EXPECT_ROUND_TRIP("https://example.com/a/b/..", "https://example.com/a/c"); } TEST(base_with_dot_as_its_last_segment) { - EXPECT_EQ( - round_trip("https://example.com/a/b/.", "https://example.com/a/b/c"), - normalised("https://example.com/a/b/c")); + EXPECT_ROUND_TRIP("https://example.com/a/b/.", "https://example.com/a/b/c"); } TEST(base_with_dotdot_beyond_the_root) { - EXPECT_EQ(round_trip("https://example.com/../../a", "https://example.com/b"), - normalised("https://example.com/b")); + EXPECT_ROUND_TRIP("https://example.com/../../a", "https://example.com/b"); } TEST(target_is_an_empty_segment_at_the_root) { - EXPECT_EQ(round_trip("https://example.com/a/b", "https://example.com//"), - normalised("https://example.com//")); + EXPECT_ROUND_TRIP("https://example.com/a/b", "https://example.com//"); } TEST(target_is_the_base_parent_exactly) { - EXPECT_EQ(round_trip("https://example.com/a/b/c", "https://example.com/a/b"), - normalised("https://example.com/a/b")); + EXPECT_ROUND_TRIP("https://example.com/a/b/c", "https://example.com/a/b"); } TEST(single_character_segments) { - EXPECT_EQ( - round_trip("https://example.com/a/b/c", "https://example.com/a/b/d"), - normalised("https://example.com/a/b/d")); + EXPECT_ROUND_TRIP("https://example.com/a/b/c", "https://example.com/a/b/d"); } TEST(iri_base_and_target) { - EXPECT_EQ(round_trip_iri("https://example.com/caf\xC3\xA9/a", - "https://example.com/caf\xC3\xA9/b"), - "https://example.com/caf\xC3\xA9/b"); + EXPECT_IRI_ROUND_TRIP("https://example.com/caf\xC3\xA9/a", + "https://example.com/caf\xC3\xA9/b"); } TEST(iri_query) { - EXPECT_EQ(round_trip_iri("https://example.com/a/b", - "https://example.com/a/c?q=caf\xC3\xA9"), - "https://example.com/a/c?q=caf\xC3\xA9"); + EXPECT_IRI_ROUND_TRIP("https://example.com/a/b", + "https://example.com/a/c?q=caf\xC3\xA9"); } TEST(iri_fragment) { - EXPECT_EQ(round_trip_iri("https://example.com/a/b", - "https://example.com/a/c#caf\xC3\xA9"), - "https://example.com/a/c#caf\xC3\xA9"); + EXPECT_IRI_ROUND_TRIP("https://example.com/a/b", + "https://example.com/a/c#caf\xC3\xA9"); } TEST(iri_descendant) { - EXPECT_EQ(round_trip_iri("https://example.com/caf\xC3\xA9", - "https://example.com/caf\xC3\xA9/x"), - "https://example.com/caf\xC3\xA9/x"); + EXPECT_IRI_ROUND_TRIP("https://example.com/caf\xC3\xA9", + "https://example.com/caf\xC3\xA9/x"); } TEST(iri_ancestor) { - EXPECT_EQ(round_trip_iri("https://example.com/caf\xC3\xA9/x/y", - "https://example.com/caf\xC3\xA9"), - "https://example.com/caf\xC3\xA9"); + EXPECT_IRI_ROUND_TRIP("https://example.com/caf\xC3\xA9/x/y", + "https://example.com/caf\xC3\xA9"); } TEST(iri_host) { - EXPECT_EQ(round_trip_iri("https://caf\xC3\xA9.example/a/b", - "https://caf\xC3\xA9.example/a/c"), - "https://caf\xC3\xA9.example/a/c"); + EXPECT_IRI_ROUND_TRIP("https://caf\xC3\xA9.example/a/b", + "https://caf\xC3\xA9.example/a/c"); } TEST(identical_uris_with_a_fragment) { - EXPECT_EQ( - round_trip("https://example.com/foo#bar", "https://example.com/foo#bar"), - normalised("https://example.com/foo#bar")); + EXPECT_ROUND_TRIP("https://example.com/foo#bar", + "https://example.com/foo#bar"); } TEST(identical_uris_with_a_query_and_fragment) { - EXPECT_EQ(round_trip("https://example.com/foo?q=1#bar", - "https://example.com/foo?q=1#bar"), - normalised("https://example.com/foo?q=1#bar")); + EXPECT_ROUND_TRIP("https://example.com/foo?q=1#bar", + "https://example.com/foo?q=1#bar"); } TEST(identical_authority_less_uris_with_a_fragment) { - EXPECT_EQ(round_trip("schema:foo#bar", "schema:foo#bar"), - normalised("schema:foo#bar")); + EXPECT_ROUND_TRIP("schema:foo#bar", "schema:foo#bar"); } TEST(identical_uris_without_a_path_but_with_a_fragment) { - EXPECT_EQ(round_trip("https://example.com#bar", "https://example.com#bar"), - normalised("https://example.com#bar")); + EXPECT_ROUND_TRIP("https://example.com#bar", "https://example.com#bar"); } diff --git a/test/uri/uri_resolve_from_test.cc b/test/uri/uri_resolve_from_test.cc index db6736d9ae..9b97a91745 100644 --- a/test/uri/uri_resolve_from_test.cc +++ b/test/uri/uri_resolve_from_test.cc @@ -541,3 +541,17 @@ TEST(network_path_reference_with_an_empty_authority) { reference.resolve_from(base); EXPECT_EQ(reference.recompose(), "http://"); } + +TEST(a_dot_reference_resolves_to_the_base_directory) { + const sourcemeta::core::URI base{"https://example.com/a/b"}; + sourcemeta::core::URI reference{"./c"}; + reference.resolve_from(base); + EXPECT_EQ(reference.recompose(), "https://example.com/a/c"); +} + +TEST(a_dotdot_reference_steps_out_of_the_base_directory) { + const sourcemeta::core::URI base{"https://example.com/a/b"}; + sourcemeta::core::URI reference{"../c"}; + reference.resolve_from(base); + EXPECT_EQ(reference.recompose(), "https://example.com/c"); +} From 50a90ab750985e6571b8cdc7561f4c9251b83b87 Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Sun, 9 Aug 2026 18:06:26 -0300 Subject: [PATCH 7/8] Fix Signed-off-by: Juan Cruz Viotti --- .../uri/uri_relativization_round_trip_test.cc | 66 +++++++++---------- 1 file changed, 30 insertions(+), 36 deletions(-) diff --git a/test/uri/uri_relativization_round_trip_test.cc b/test/uri/uri_relativization_round_trip_test.cc index 730bc9bc55..6e34bbcaab 100644 --- a/test/uri/uri_relativization_round_trip_test.cc +++ b/test/uri/uri_relativization_round_trip_test.cc @@ -1,8 +1,6 @@ #include #include -#include // std::string - // RFC 3986 defines resolution but no way to compute a relative reference, so // the correctness of relativization is defined by resolution undoing it: // @@ -14,43 +12,39 @@ // implementation that relativization is measured against rather than as // another moving part. -static auto round_trip(const std::string &base_string, - const std::string &target_string) -> std::string { - const sourcemeta::core::URI base{base_string}; - sourcemeta::core::URI target{target_string}; - // RFC 3986 Section 5.1 resolves against an absolute base, and a relative - // target is not something the equation can reproduce - EXPECT_TRUE(base.is_absolute()); - EXPECT_TRUE(target.is_absolute()); - target.relative_to(base); - sourcemeta::core::URI resolved{target.recompose()}; - resolved.resolve_from(base); - return resolved.recompose(); -} +// Both preconditions are asserted rather than assumed. RFC 3986 Section 5.1 +// resolves against an absolute base, and a relative target is not something the +// equation can reproduce. The expected value is the target parsed and +// recomposed rather than the target string itself, because the parser decodes +// percent-encoded unreserved characters and that has nothing to do with +// relativization +#define EXPECT_ROUND_TRIP(base_string, target_string) \ + { \ + const sourcemeta::core::URI base{(base_string)}; \ + sourcemeta::core::URI target{(target_string)}; \ + EXPECT_TRUE(base.is_absolute()); \ + EXPECT_TRUE(target.is_absolute()); \ + target.relative_to(base); \ + sourcemeta::core::URI resolved{target.recompose()}; \ + resolved.resolve_from(base); \ + EXPECT_EQ(resolved.recompose(), \ + sourcemeta::core::URI{(target_string)}.recompose()); \ + } // RFC 3986 is ASCII only, so a non-ASCII input has to travel the RFC 3987 path // from parsing through to recomposition for the equation to mean anything -static auto round_trip_iri(const std::string &base_string, - const std::string &target_string) -> std::string { - const auto base{sourcemeta::core::URI::from_iri(base_string)}; - auto target{sourcemeta::core::URI::from_iri(target_string)}; - EXPECT_TRUE(base.is_absolute()); - EXPECT_TRUE(target.is_absolute()); - target.relative_to(base); - auto resolved{sourcemeta::core::URI::from_iri(target.recompose())}; - resolved.resolve_from(base); - return resolved.recompose(); -} - -// The expected value is the target parsed and recomposed, rather than the -// target string itself, because the parser decodes percent-encoded unreserved -// characters and that has nothing to do with relativization -#define EXPECT_ROUND_TRIP(base, target) \ - EXPECT_EQ(round_trip((base), (target)), \ - sourcemeta::core::URI{(target)}.recompose()); - -#define EXPECT_IRI_ROUND_TRIP(base, target) \ - EXPECT_EQ(round_trip_iri((base), (target)), (target)); +#define EXPECT_IRI_ROUND_TRIP(base_string, target_string) \ + { \ + const auto base{sourcemeta::core::URI::from_iri(base_string)}; \ + auto target{sourcemeta::core::URI::from_iri(target_string)}; \ + EXPECT_TRUE(base.is_absolute()); \ + EXPECT_TRUE(target.is_absolute()); \ + target.relative_to(base); \ + auto resolved{sourcemeta::core::URI::from_iri(target.recompose())}; \ + resolved.resolve_from(base); \ + EXPECT_EQ(resolved.recompose(), \ + sourcemeta::core::URI::from_iri(target_string).recompose()); \ + } TEST(absolute_absolute_base_true_1) { EXPECT_ROUND_TRIP("https://www.example.com", "https://www.example.com/foo"); From 10b8b4f88d872db4cc12aef8903994c3f17fbbc5 Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Sun, 9 Aug 2026 18:13:28 -0300 Subject: [PATCH 8/8] More Signed-off-by: Juan Cruz Viotti --- src/core/uri/accessors.cc | 5 +++- src/core/uri/include/sourcemeta/core/uri.h | 10 ++++---- src/core/uri/resolution.cc | 24 ++++++------------- test/uri/uri_has_same_authority_test.cc | 7 ++++++ test/uri/uri_rebase_test.cc | 14 +++++++++++ test/uri/uri_relative_to_test.cc | 7 ++++++ .../uri/uri_relativization_round_trip_test.cc | 8 +++++++ 7 files changed, 53 insertions(+), 22 deletions(-) diff --git a/src/core/uri/accessors.cc b/src/core/uri/accessors.cc index 73f7fc49a8..ce4887fa44 100644 --- a/src/core/uri/accessors.cc +++ b/src/core/uri/accessors.cc @@ -136,8 +136,11 @@ auto URI::userinfo() const -> std::optional { } auto URI::has_same_authority(const URI &other) const noexcept -> bool { + // RFC 3986 Section 3.2.2 wraps an IP literal in brackets and writes every + // other host bare, so a host that reads the same either way still names a + // different authority depending on which form it takes return this->userinfo_ == other.userinfo_ && this->host_ == other.host_ && - this->port_ == other.port_; + this->port_ == other.port_ && this->ip_literal_ == other.ip_literal_; } } // namespace sourcemeta::core diff --git a/src/core/uri/include/sourcemeta/core/uri.h b/src/core/uri/include/sourcemeta/core/uri.h index 29f23a2f20..27daf475b8 100644 --- a/src/core/uri/include/sourcemeta/core/uri.h +++ b/src/core/uri/include/sourcemeta/core/uri.h @@ -665,10 +665,12 @@ class SOURCEMETA_CORE_URI_EXPORT URI { /// /// That equation is the definition of a correct result, as RFC 3986 states /// how to resolve a reference but never how to compute one. It holds when - /// both URIs are absolute and the target path carries no dot segments, which - /// resolution always removes and so can never reproduce. When no such - /// reference exists, the URI is left intact, which satisfies the equation - /// too. For example: + /// both URIs are absolute and the target path carries no dot segments. + /// Resolution strips those from a reference that keeps its scheme just as it + /// does from a relative one, so a target carrying them is outside the + /// guarantee whether or not a reference gets built. Within those bounds, a + /// URI left intact because no reference expresses it satisfies the equation + /// as well. For example: /// /// ```cpp /// #include diff --git a/src/core/uri/resolution.cc b/src/core/uri/resolution.cc index 3784390030..898b72486c 100644 --- a/src/core/uri/resolution.cc +++ b/src/core/uri/resolution.cc @@ -141,15 +141,7 @@ auto URI::relative_to(const URI &base) -> URI & { } // The full authority must match (but components can be null for URNs) - if (this->userinfo_ != base.userinfo_) { - return *this; - } - - if (this->host_ != base.host_) { - return *this; - } - - if (this->port_ != base.port_) { + if (!this->has_same_authority(base)) { return *this; } @@ -349,16 +341,14 @@ auto merge_new_base_path(std::optional &target_path, if (new_base_path.has_value() && saved_path.has_value()) { auto merged{std::move(new_base_path.value())}; const auto &relative_path = saved_path.value(); - const auto base_ends_with_slash = merged.ends_with('/'); - const auto relative_starts_with_slash = relative_path.starts_with('/'); - if (base_ends_with_slash && relative_starts_with_slash) { - merged.append(relative_path, 1); - } else if (!base_ends_with_slash && !relative_starts_with_slash) { + // The suffix is what lies below the old base with the separating slash + // already removed, so a slash it does start with opens an empty segment + // and must not be mistaken for that separator + if (!merged.empty() && !merged.ends_with('/')) { merged += '/'; - merged += relative_path; - } else { - merged += relative_path; } + + merged += relative_path; target_path = std::move(merged); } else if (new_base_path.has_value()) { target_path = std::move(new_base_path); diff --git a/test/uri/uri_has_same_authority_test.cc b/test/uri/uri_has_same_authority_test.cc index 80269f166c..245aacb348 100644 --- a/test/uri/uri_has_same_authority_test.cc +++ b/test/uri/uri_has_same_authority_test.cc @@ -102,3 +102,10 @@ TEST(iri_same_unicode_host) { "https://\xE4\xBE\x8B\xE3\x81\x88.jp/bar")}; EXPECT_TRUE(left.has_same_authority(right)); } + +TEST(ip_literal_vs_registered_name_of_the_same_text) { + const sourcemeta::core::URI left{"https://[v1.x]/foo"}; + const sourcemeta::core::URI right{"https://v1.x/foo"}; + EXPECT_FALSE(left.has_same_authority(right)); + EXPECT_FALSE(right.has_same_authority(left)); +} diff --git a/test/uri/uri_rebase_test.cc b/test/uri/uri_rebase_test.cc index fd1aeb7955..d978fb6711 100644 --- a/test/uri/uri_rebase_test.cc +++ b/test/uri/uri_rebase_test.cc @@ -201,3 +201,17 @@ TEST(a_base_with_a_trailing_slash_matches_the_same_suffix) { sourcemeta::core::URI{"/new"}); EXPECT_EQ(uri.recompose(), "/new/x"); } + +TEST(an_empty_segment_below_the_base_is_preserved) { + sourcemeta::core::URI uri{"https://example.com/foo//bar"}; + uri.rebase(sourcemeta::core::URI{"https://example.com/foo"}, + sourcemeta::core::URI{"/qux"}); + EXPECT_EQ(uri.recompose(), "/qux//bar"); +} + +TEST(an_ip_literal_base_does_not_match_a_registered_name) { + sourcemeta::core::URI uri{"https://v1.x/foo/bar"}; + uri.rebase(sourcemeta::core::URI{"https://[v1.x]/foo"}, + sourcemeta::core::URI{"/qux"}); + EXPECT_EQ(uri.recompose(), "https://v1.x/foo/bar"); +} diff --git a/test/uri/uri_relative_to_test.cc b/test/uri/uri_relative_to_test.cc index 4bdeaab988..3fe8f53c47 100644 --- a/test/uri/uri_relative_to_test.cc +++ b/test/uri/uri_relative_to_test.cc @@ -643,3 +643,10 @@ TEST(target_ending_in_a_dotdot_segment) { uri.relative_to(base); EXPECT_EQ(uri.recompose(), ".."); } + +TEST(ip_literal_base_and_registered_name_target_stay_absolute) { + const sourcemeta::core::URI base{"https://[v1.x]/a/b"}; + sourcemeta::core::URI uri{"https://v1.x/a/c"}; + uri.relative_to(base); + EXPECT_EQ(uri.recompose(), "https://v1.x/a/c"); +} diff --git a/test/uri/uri_relativization_round_trip_test.cc b/test/uri/uri_relativization_round_trip_test.cc index 6e34bbcaab..28170a1f42 100644 --- a/test/uri/uri_relativization_round_trip_test.cc +++ b/test/uri/uri_relativization_round_trip_test.cc @@ -708,3 +708,11 @@ TEST(identical_authority_less_uris_with_a_fragment) { TEST(identical_uris_without_a_path_but_with_a_fragment) { EXPECT_ROUND_TRIP("https://example.com#bar", "https://example.com#bar"); } + +TEST(ip_literal_base_with_registered_name_target) { + EXPECT_ROUND_TRIP("https://[v1.x]/a/b", "https://v1.x/a/c"); +} + +TEST(registered_name_base_with_ip_literal_target) { + EXPECT_ROUND_TRIP("https://v1.x/a/b", "https://[v1.x]/a/c"); +}