diff --git a/src/core/uri/accessors.cc b/src/core/uri/accessors.cc index 73f7fc49a..ce4887fa4 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 d0c5e9397..27daf475b 100644 --- a/src/core/uri/include/sourcemeta/core/uri.h +++ b/src/core/uri/include/sourcemeta/core/uri.h @@ -656,22 +656,37 @@ 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. + /// 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 /// #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 +700,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 diff --git a/src/core/uri/recompose.cc b/src/core/uri/recompose.cc index e746ea564..f7f1679d8 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 cb52fb6b2..898b72486 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()) { @@ -137,29 +141,23 @@ 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; } // 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; } @@ -227,43 +225,29 @@ 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) + // 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(); @@ -280,17 +264,13 @@ 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 // like "/foo" (only one level deep) as that's not meaningfully navigable - const auto base_parent = base_last_slash != std::string::npos - ? base_path.substr(0, base_last_slash + 1) - : base_path; - 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)) { @@ -361,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); @@ -379,59 +357,69 @@ auto merge_new_base_path(std::optional &target_path, } } +// 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(uri.path().value_or(""), + base.path().value_or("")); +} + } // namespace auto URI::rebase(const URI &base, const URI &new_base) -> URI & { - this->relative_to(base); - if (!this->is_relative()) { + auto suffix{path_under(*this, 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_; 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_; 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{path_under(*this, 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_); 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_; 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 d9c5a5964..b73030510 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_has_same_authority_test.cc b/test/uri/uri_has_same_authority_test.cc index 80269f166..245aacb34 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_parse_test.cc b/test/uri/uri_parse_test.cc index eee98b2f9..bde63c34c 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_rebase_test.cc b/test/uri/uri_rebase_test.cc index abb68389e..d978fb671 100644 --- a/test/uri/uri_rebase_test.cc +++ b/test/uri/uri_rebase_test.cc @@ -56,3 +56,162 @@ 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"); +} + +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"); +} + +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"); +} + +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_recompose_relative_test.cc b/test/uri/uri_recompose_relative_test.cc index 84d8c0dab..007323be6 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 0b91e98da..3fe8f53c4 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,107 @@ 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"); +} + +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"); +} + +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"); +} + +// 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(), ".."); +} + +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 new file mode 100644 index 000000000..28170a1f4 --- /dev/null +++ b/test/uri/uri_relativization_round_trip_test.cc @@ -0,0 +1,718 @@ +#include +#include + +// 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 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 +#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"); +} + +TEST(absolute_absolute_base_true_2) { + EXPECT_ROUND_TRIP("https://www.example.com/foo", + "https://www.example.com/foo"); +} + +TEST(absolute_absolute_base_true_3) { + EXPECT_ROUND_TRIP("https://www.example.com/foo", + "https://www.example.com/foo/bar?q=1"); +} + +TEST(absolute_absolute_base_true_4) { + EXPECT_ROUND_TRIP("https://www.example.com/foo", + "https://www.example.com/foo/bar#baz"); +} + +TEST(absolute_absolute_base_false_1) { + EXPECT_ROUND_TRIP("https://www.example.com/foo", + "http://www.example.com/foo"); +} + +TEST(absolute_absolute_base_false_2) { + EXPECT_ROUND_TRIP("https://www.example.com/foo", "https://www.example.com"); +} + +TEST(absolute_absolute_base_false_3) { + EXPECT_ROUND_TRIP("https://www.example.com/foo/bar", + "https://www.example.com/foo"); +} + +TEST(absolute_absolute_base_false_4) { + EXPECT_ROUND_TRIP("https://foo.com", "https://bar.com"); +} + +TEST(absolute_absolute_base_false_different_ports) { + EXPECT_ROUND_TRIP("http://localhost:8000", + "http://localhost:9000/schemas/test.json"); +} + +TEST(absolute_absolute_base_false_different_userinfo) { + EXPECT_ROUND_TRIP("https://alice@example.com/foo", + "https://bob@example.com/foo/bar"); +} + +TEST(absolute_absolute_base_false_userinfo_vs_none) { + EXPECT_ROUND_TRIP("https://example.com/foo", + "https://alice@example.com/foo/bar"); +} + +TEST(urn_1) { EXPECT_ROUND_TRIP("schema:", "schema:myschema"); } + +TEST(absolute_absolute_trailing_slash) { + 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_ROUND_TRIP("https://example.com/foo/bar/baz", + "https://example.com/foo"); +} + +TEST(target_is_one_level_up_at_root) { + EXPECT_ROUND_TRIP("https://example.com/foo/bar", "https://example.com/foo"); +} + +TEST(target_is_root) { + EXPECT_ROUND_TRIP("https://example.com/foo/bar", "https://example.com/"); +} + +TEST(base_ends_with_slash) { + EXPECT_ROUND_TRIP("https://example.com/foo/", "https://example.com/foo/bar"); +} + +TEST(base_root_only) { + EXPECT_ROUND_TRIP("https://example.com/", "https://example.com/foo"); +} + +TEST(sibling_paths_same_directory) { + EXPECT_ROUND_TRIP("https://example.com/schemas/bar.json", + "https://example.com/schemas/foo.json"); +} + +TEST(double_slash_with_trailing_slash) { + EXPECT_ROUND_TRIP("https://example.com/slash/", + "https://example.com/slash/file.json"); +} + +TEST(different_directories_same_host_needs_dotdot) { + 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_ROUND_TRIP("https://example.com/foo/bar/baz.json", + "https://example.com/qux/test.json"); +} + +TEST(different_directories_same_host_needs_dotdot_3) { + EXPECT_ROUND_TRIP("https://example.com/a/b/c.json", + "https://example.com/d.json"); +} + +TEST(file_same_directory) { + EXPECT_ROUND_TRIP("file:///home/user/schemas/base.json", + "file:///home/user/schemas/other.json"); +} + +TEST(file_subdirectory) { + EXPECT_ROUND_TRIP("file:///home/user/schemas", + "file:///home/user/schemas/sub/test.json"); +} + +TEST(file_parent_directory) { + EXPECT_ROUND_TRIP("file:///home/user/schemas/sub/base.json", + "file:///home/user/schemas/other.json"); +} + +TEST(file_different_root) { + EXPECT_ROUND_TRIP("file:///home/user/schemas/base.json", + "file:///var/data/test.json"); +} + +TEST(file_same_file) { + EXPECT_ROUND_TRIP("file:///home/user/schemas/base.json", + "file:///home/user/schemas/base.json"); +} + +TEST(file_with_fragment) { + EXPECT_ROUND_TRIP("file:///home/user/schemas/base.json", + "file:///home/user/schemas/other.json#/defs/foo"); +} + +TEST(file_windows_same_directory) { + EXPECT_ROUND_TRIP("file:///C:/Users/user/schemas/base.json", + "file:///C:/Users/user/schemas/other.json"); +} + +TEST(file_windows_subdirectory) { + EXPECT_ROUND_TRIP("file:///C:/Users/user/schemas", + "file:///C:/Users/user/schemas/sub/test.json"); +} + +TEST(file_windows_parent_directory) { + EXPECT_ROUND_TRIP("file:///C:/Users/user/schemas/sub/base.json", + "file:///C:/Users/user/schemas/other.json"); +} + +TEST(file_windows_different_drive) { + EXPECT_ROUND_TRIP("file:///C:/Users/user/schemas/base.json", + "file:///D:/Data/test.json"); +} + +TEST(file_windows_same_file) { + EXPECT_ROUND_TRIP("file:///C:/Users/user/schemas/base.json", + "file:///C:/Users/user/schemas/base.json"); +} + +TEST(file_windows_with_fragment) { + 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_ROUND_TRIP("https://www.example.com/foo/bar", + "https://www.example.com/foo/bar?q=1"); +} + +TEST(same_path_only_fragment_differs) { + EXPECT_ROUND_TRIP("https://www.example.com/foo/bar", + "https://www.example.com/foo/bar#baz"); +} + +TEST(same_path_query_and_fragment_differ) { + 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_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_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_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_ROUND_TRIP("https://www.example.com/foo/?q=1", + "https://www.example.com/foo/"); +} + +TEST(same_directory_path_base_query_target_fragment) { + EXPECT_ROUND_TRIP("https://www.example.com/foo/?q=1", + "https://www.example.com/foo/#baz"); +} + +TEST(same_directory_path_target_query) { + 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_ROUND_TRIP("https://www.example.com", "https://www.example.com#baz"); +} + +TEST(authority_no_path_only_query_differs) { + EXPECT_ROUND_TRIP("https://www.example.com?q=1", + "https://www.example.com?q=2"); +} + +TEST(authority_no_path_base_query_target_none) { + EXPECT_ROUND_TRIP("https://www.example.com?q=1", "https://www.example.com"); +} + +TEST(relative_to_same_path_with_query_in_base) { + EXPECT_ROUND_TRIP("schema:foo", "schema:foo?bar=1"); +} + +TEST(relative_to_slashless_base_path) { + EXPECT_ROUND_TRIP("schema:foo", "schema:bar"); +} + +TEST(target_is_directory_of_base) { + EXPECT_ROUND_TRIP("https://example.com/test/foo.json", + "https://example.com/test/"); +} + +TEST(target_is_nested_directory_of_base) { + EXPECT_ROUND_TRIP("https://example.com/foo/bar/baz.json", + "https://example.com/foo/bar/"); +} + +TEST(target_is_directory_of_base_with_query) { + EXPECT_ROUND_TRIP("https://example.com/test/foo.json", + "https://example.com/test/?q=1"); +} + +TEST(target_is_directory_of_base_with_fragment) { + EXPECT_ROUND_TRIP("https://example.com/test/foo.json", + "https://example.com/test/#baz"); +} + +TEST(file_target_is_directory_of_base) { + EXPECT_ROUND_TRIP("file:///home/user/schemas/base.json", + "file:///home/user/schemas/"); +} + +TEST(target_is_root_of_base_without_path) { + EXPECT_ROUND_TRIP("https://example.com", "https://example.com/"); +} + +TEST(target_with_empty_first_segment_of_base_without_path) { + EXPECT_ROUND_TRIP("https://example.com", "https://example.com//foo"); +} + +TEST(target_with_empty_first_segment_of_root_base) { + EXPECT_ROUND_TRIP("https://example.com/foo", "https://example.com//bar"); +} + +TEST(target_with_empty_segment_below_base) { + EXPECT_ROUND_TRIP("https://example.com/foo", "https://example.com/foo//bar"); +} + +TEST(target_with_empty_first_segment_from_nested_base) { + EXPECT_ROUND_TRIP("https://example.com/a/b", "https://example.com//bar"); +} + +TEST(target_with_empty_segment_below_directory_base) { + EXPECT_ROUND_TRIP("https://example.com/foo/", "https://example.com/foo//bar"); +} + +TEST(target_with_empty_segment_below_nested_directory_base) { + 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_ROUND_TRIP("schema:", "schema:/foo"); +} + +TEST(sibling_with_colon_in_first_segment) { + 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_ROUND_TRIP("https://example.com/foo", "https://example.com/foo/bar"); +} + +TEST(base_with_fragment) { + EXPECT_ROUND_TRIP("https://example.com/a/b#frag", "https://example.com/a/c"); +} + +TEST(base_with_query) { + EXPECT_ROUND_TRIP("https://example.com/a/b?q=1", "https://example.com/a/c"); +} + +TEST(userinfo) { + EXPECT_ROUND_TRIP("https://user:pass@example.com/a/b", + "https://user:pass@example.com/a/c"); +} + +TEST(non_default_port) { + EXPECT_ROUND_TRIP("https://example.com:8443/a/b", + "https://example.com:8443/a/c"); +} + +TEST(percent_encoded_space) { + EXPECT_ROUND_TRIP("https://example.com/a/b", "https://example.com/a/c%20d"); +} + +TEST(percent_encoded_unreserved) { + EXPECT_ROUND_TRIP("https://example.com/a/b", "https://example.com/a/c%41d"); +} + +TEST(percent_encoded_slash) { + EXPECT_ROUND_TRIP("https://example.com/a/b", "https://example.com/a/c%2Fd"); +} + +TEST(literal_dotdot_prefix_segment) { + EXPECT_ROUND_TRIP("https://example.com/a/b", "https://example.com/a/..foo"); +} + +TEST(literal_dotdot_suffix_segment) { + EXPECT_ROUND_TRIP("https://example.com/a/b", "https://example.com/a/foo.."); +} + +TEST(literal_dot_prefix_segment) { + EXPECT_ROUND_TRIP("https://example.com/a/b", "https://example.com/a/.foo"); +} + +TEST(target_without_path) { + EXPECT_ROUND_TRIP("https://example.com/a/b", "https://example.com"); +} + +TEST(identical_without_path) { + EXPECT_ROUND_TRIP("https://example.com", "https://example.com"); +} + +TEST(foreign_authority) { + EXPECT_ROUND_TRIP("https://example.com/a/b", "https://other.com/x"); +} + +TEST(slashless_base) { EXPECT_ROUND_TRIP("schema:foo", "schema:bar"); } + +TEST(deep_target_from_root_base) { + EXPECT_ROUND_TRIP("https://example.com/", "https://example.com/a/b/c"); +} + +TEST(parent_of_base_directory) { + EXPECT_ROUND_TRIP("https://example.com/a/", "https://example.com/"); +} + +TEST(query_and_fragment) { + EXPECT_ROUND_TRIP("https://example.com/a/b", "https://example.com/a/b?q=1#f"); +} + +TEST(windows_drive_sibling) { + EXPECT_ROUND_TRIP("file:///C:/x/y.json", "file:///C:/x/D:foo.json"); +} + +TEST(target_with_trailing_slash_at_depth) { + EXPECT_ROUND_TRIP("https://example.com/a/b/c", "https://example.com/a/b/d/"); +} + +TEST(target_is_base_with_trailing_slash) { + EXPECT_ROUND_TRIP("https://example.com/a/b", "https://example.com/a/b/"); +} + +TEST(deep_descendant) { + EXPECT_ROUND_TRIP("https://example.com/a/b", "https://example.com/a/b/c/d"); +} + +TEST(descendant_with_query_and_fragment) { + 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_ROUND_TRIP("https://example.com/a/b..", "https://example.com/a/b../c"); +} + +TEST(windows_descendant) { + EXPECT_ROUND_TRIP("file:///C:/x", "file:///C:/x/y.json"); +} + +TEST(colon_in_base_last_segment_descendant) { + EXPECT_ROUND_TRIP("https://example.com/D:foo", + "https://example.com/D:foo/bar"); +} + +TEST(descendant_with_empty_segment) { + EXPECT_ROUND_TRIP("https://example.com/a/b", "https://example.com/a/b//c"); +} + +TEST(descendant_of_single_segment_base) { + EXPECT_ROUND_TRIP("https://example.com/a", "https://example.com/a/b"); +} + +TEST(iri_preserves_ucschar) { + EXPECT_IRI_ROUND_TRIP("https://example.com/dir/", + "https://example.com/dir/caf\xC3\xA9"); +} + +TEST(slashless_base_with_descendant_target) { + EXPECT_ROUND_TRIP("schema:foo", "schema:foo/bar"); +} + +TEST(ipv6_host) { EXPECT_ROUND_TRIP("https://[::1]/a/b", "https://[::1]/a/c"); } + +TEST(parent_of_nested_directory_base) { + EXPECT_ROUND_TRIP("https://example.com/a/b/", "https://example.com/a/"); +} + +TEST(ancestor_two_levels_up) { + EXPECT_ROUND_TRIP("https://example.com/a/b/c", "https://example.com/a"); +} + +TEST(directory_base_with_target_of_the_same_name) { + EXPECT_ROUND_TRIP("https://example.com/a/", "https://example.com/a"); +} + +TEST(file_base_with_directory_target_of_the_same_name) { + EXPECT_ROUND_TRIP("https://example.com/a", "https://example.com/a/"); +} + +TEST(windows_drive_root_from_nested_base) { + EXPECT_ROUND_TRIP("file:///C:/x/y/", "file:///C:/"); +} + +TEST(identical_without_path_but_with_query) { + EXPECT_ROUND_TRIP("https://example.com?q=1", "https://example.com?q=1"); +} + +TEST(ancestor_three_levels_up) { + EXPECT_ROUND_TRIP("https://example.com/a/b/c/d", "https://example.com/a"); +} + +TEST(mailto_slashless_base) { EXPECT_ROUND_TRIP("mailto:foo", "mailto:bar"); } + +TEST(target_with_empty_fragment) { + EXPECT_ROUND_TRIP("https://example.com/a/b", "https://example.com/a/b#"); +} + +TEST(target_with_empty_query) { + EXPECT_ROUND_TRIP("https://example.com/a/b", "https://example.com/a/b?"); +} + +TEST(sibling_carrying_the_same_query) { + 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_ROUND_TRIP("https://example.com/", "https://example.com"); +} + +TEST(descendant_of_a_base_with_an_empty_first_segment) { + EXPECT_ROUND_TRIP("https://example.com//a", "https://example.com//a/b"); +} + +TEST(target_gains_an_empty_first_segment) { + EXPECT_ROUND_TRIP("https://example.com/a", "https://example.com//a"); +} + +TEST(base_with_dot_segment) { + EXPECT_ROUND_TRIP("https://example.com/a/./b", "https://example.com/a/c"); +} + +TEST(base_with_dotdot_segment) { + EXPECT_ROUND_TRIP("https://example.com/a/../b", "https://example.com/a/c"); +} + +TEST(uppercase_scheme_in_base) { + EXPECT_ROUND_TRIP("HTTPS://example.com/a/b", "https://example.com/a/c"); +} + +TEST(uppercase_host_in_base) { + EXPECT_ROUND_TRIP("https://EXAMPLE.COM/a/b", "https://example.com/a/c"); +} + +TEST(explicit_default_port) { + EXPECT_ROUND_TRIP("https://example.com:443/a/b", + "https://example.com:443/a/c"); +} + +TEST(base_with_port_and_target_without) { + EXPECT_ROUND_TRIP("https://example.com:443/a/b", "https://example.com/a/c"); +} + +TEST(deep_ancestor_traversal) { + EXPECT_ROUND_TRIP("https://example.com/a/b/c/d/e/f", "https://example.com/x"); +} + +TEST(repeated_empty_segments) { + EXPECT_ROUND_TRIP("https://example.com/a//b//c", + "https://example.com/a//b//d"); +} + +TEST(file_with_localhost_authority) { + EXPECT_ROUND_TRIP("file://localhost/a/b", "file://localhost/a/c"); +} + +TEST(file_with_empty_authority) { + EXPECT_ROUND_TRIP("file:///a/b", "file:///a/c"); +} + +TEST(scheme_with_plus_hyphen_and_dot) { + EXPECT_ROUND_TRIP("a+b-c.d:/x/y", "a+b-c.d:/x/z"); +} + +TEST(query_containing_slashes) { + EXPECT_ROUND_TRIP("https://example.com/a/b", + "https://example.com/a/c?x=/y/z"); +} + +TEST(fragment_containing_slashes) { + EXPECT_ROUND_TRIP("https://example.com/a/b", + "https://example.com/a/c#/defs/x"); +} + +TEST(fragment_containing_question_mark) { + EXPECT_ROUND_TRIP("https://example.com/a/b", "https://example.com/a/c#x?y"); +} + +TEST(userinfo_with_colon) { + EXPECT_ROUND_TRIP("https://u:p@example.com/a/b", + "https://u:p@example.com/a/b/c"); +} + +TEST(percent_encoded_userinfo) { + EXPECT_ROUND_TRIP("https://u%40x@example.com/a/b", + "https://u%40x@example.com/a/c"); +} + +TEST(path_with_semicolon_parameters) { + EXPECT_ROUND_TRIP("https://example.com/a/b;x=1", + "https://example.com/a/c;y=2"); +} + +TEST(path_segment_with_at_sign) { + EXPECT_ROUND_TRIP("https://example.com/a/b", "https://example.com/a/@c"); +} + +TEST(deeply_nested_descendant) { + 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_ROUND_TRIP("https://example.com/foo", + "https://example.com/foobar/baz"); +} + +TEST(target_of_only_slashes) { + EXPECT_ROUND_TRIP("https://example.com/a", "https://example.com///"); +} + +TEST(base_of_only_slashes) { + EXPECT_ROUND_TRIP("https://example.com///", "https://example.com/a"); +} + +TEST(root_base_with_empty_first_segment_target) { + EXPECT_ROUND_TRIP("https://example.com/", "https://example.com//x"); +} + +TEST(urn_siblings) { EXPECT_ROUND_TRIP("urn:example:a", "urn:example:b"); } + +TEST(tag_uri_siblings) { + EXPECT_ROUND_TRIP("tag:example.com,2024:a", "tag:example.com,2024:b"); +} + +TEST(long_ancestor_chain) { + 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_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_ROUND_TRIP("https://example.com/a/b/..", "https://example.com/a/c"); +} + +TEST(base_with_dot_as_its_last_segment) { + EXPECT_ROUND_TRIP("https://example.com/a/b/.", "https://example.com/a/b/c"); +} + +TEST(base_with_dotdot_beyond_the_root) { + EXPECT_ROUND_TRIP("https://example.com/../../a", "https://example.com/b"); +} + +TEST(target_is_an_empty_segment_at_the_root) { + EXPECT_ROUND_TRIP("https://example.com/a/b", "https://example.com//"); +} + +TEST(target_is_the_base_parent_exactly) { + EXPECT_ROUND_TRIP("https://example.com/a/b/c", "https://example.com/a/b"); +} + +TEST(single_character_segments) { + EXPECT_ROUND_TRIP("https://example.com/a/b/c", "https://example.com/a/b/d"); +} + +TEST(iri_base_and_target) { + EXPECT_IRI_ROUND_TRIP("https://example.com/caf\xC3\xA9/a", + "https://example.com/caf\xC3\xA9/b"); +} + +TEST(iri_query) { + EXPECT_IRI_ROUND_TRIP("https://example.com/a/b", + "https://example.com/a/c?q=caf\xC3\xA9"); +} + +TEST(iri_fragment) { + EXPECT_IRI_ROUND_TRIP("https://example.com/a/b", + "https://example.com/a/c#caf\xC3\xA9"); +} + +TEST(iri_descendant) { + EXPECT_IRI_ROUND_TRIP("https://example.com/caf\xC3\xA9", + "https://example.com/caf\xC3\xA9/x"); +} + +TEST(iri_ancestor) { + EXPECT_IRI_ROUND_TRIP("https://example.com/caf\xC3\xA9/x/y", + "https://example.com/caf\xC3\xA9"); +} + +TEST(iri_host) { + 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_ROUND_TRIP("https://example.com/foo#bar", + "https://example.com/foo#bar"); +} + +TEST(identical_uris_with_a_query_and_fragment) { + 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_ROUND_TRIP("schema:foo#bar", "schema:foo#bar"); +} + +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"); +} diff --git a/test/uri/uri_resolve_from_test.cc b/test/uri/uri_resolve_from_test.cc index e81af8aab..9b97a9174 100644 --- a/test/uri/uri_resolve_from_test.cc +++ b/test/uri/uri_resolve_from_test.cc @@ -489,3 +489,69 @@ 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"); +} + +// 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://"); +} + +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"); +}