//SPDX-License-Identifier: LGPL-3.0-only //SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® // URL reads an absolute URL as RFC 3986 does and resolves a reference // against it as §5.2 says, which is what ClientHTTP1::Fetch leans on to // follow a Location. The resolution cases are RFC 3986 §5.4's own examples, // normal and abnormal, against its base `http://a/b/c/d;p?q`; the parse // cases guard the pieces a request is built from (the default port, the // IPv6 brackets, the userinfo that must not reach the host) and what is // refused. #include import Crafter.Network; import std; using namespace Crafter; namespace { int failures = 0; void Check(bool ok, std::string_view what) { std::println("{} {}", ok ? "PASS" : "FAIL", what); if (!ok) ++failures; } bool Refused(std::string_view text) { try { URL::Parse(text); } catch (const std::invalid_argument&) { return true; } return false; } } // namespace int main() { { const URL url = URL::Parse("HTTP://User:pw@Example.COM:8080/a/b?x=1&y#frag"); Check(url.scheme == "http" && url.host == "example.com", "the scheme and the host are lowercased"); Check(url.userinfo == "User:pw", "the userinfo is kept apart from the host"); Check(url.port && *url.port == 8080 && url.PortOrDefault() == 8080, "an explicit port"); Check(url.path == "/a/b" && url.query && *url.query == "x=1&y" && url.fragment && *url.fragment == "frag", "path, query and fragment"); Check(url.Authority() == "example.com:8080" && url.Target() == "/a/b?x=1&y", "the Host value and the request target"); Check(url.ToString() == "http://User:pw@example.com:8080/a/b?x=1&y#frag", std::format("recomposed ({})", url.ToString())); } { const URL plain = URL::Parse("http://example.com"); const URL secure = URL::Parse("https://example.com/"); Check(!plain.port && plain.PortOrDefault() == 80 && secure.PortOrDefault() == 443, "the scheme's default port when none is named"); Check(plain.path.empty() && plain.Target() == "/" && plain.Authority() == "example.com", "no path is the target `/`, and the Host value carries no port"); Check(!plain.query && URL::Parse("http://example.com/?").query && URL::Parse("http://example.com/?").query->empty(), "no query is none; `?` alone is an empty one"); Check(!plain.SameOrigin(secure) && plain.SameOrigin(URL::Parse("http://EXAMPLE.com:80/other")) && !plain.SameOrigin(URL::Parse("http://example.com:8080/")), "the origin is the scheme, the host and the effective port"); } { const URL v6 = URL::Parse("http://[::1]:8130/x"); Check(v6.host == "::1" && v6.port && *v6.port == 8130 && v6.Authority() == "[::1]:8130" && v6.ToString() == "http://[::1]:8130/x", std::format("an IPv6 host loses its brackets in `host` and gets them back in the Host value ({})", v6.ToString())); } Check(Refused("example.com/path"), "text without a scheme is refused"); Check(Refused("http:///path"), "a URL without a host is refused"); Check(Refused("http://example.com:99999/"), "a port above 65535 is refused"); Check(Refused("http://example.com:8x/"), "a port that is not a number is refused"); Check(Refused("http://[::1/"), "unbalanced brackets are refused"); Check(URL::Parse("http://example.com:/").PortOrDefault() == 80, "an empty port is the default"); // RFC 3986 §5.4. const URL base = URL::Parse("http://a/b/c/d;p?q"); const std::vector> cases = { {"g", "http://a/b/c/g"}, {"./g", "http://a/b/c/g"}, {"g/", "http://a/b/c/g/"}, {"/g", "http://a/g"}, {"//g", "http://g"}, {"?y", "http://a/b/c/d;p?y"}, {"g?y", "http://a/b/c/g?y"}, {"#s", "http://a/b/c/d;p?q#s"}, {"g#s", "http://a/b/c/g#s"}, {"g?y#s", "http://a/b/c/g?y#s"}, {";x", "http://a/b/c/;x"}, {"g;x", "http://a/b/c/g;x"}, {"g;x?y#s", "http://a/b/c/g;x?y#s"}, {"", "http://a/b/c/d;p?q"}, {".", "http://a/b/c/"}, {"./", "http://a/b/c/"}, {"..", "http://a/b/"}, {"../", "http://a/b/"}, {"../g", "http://a/b/g"}, {"../..", "http://a/"}, {"../../", "http://a/"}, {"../../g", "http://a/g"}, // §5.4.2, the abnormal ones. {"../../../g", "http://a/g"}, {"../../../../g", "http://a/g"}, {"/./g", "http://a/g"}, {"/../g", "http://a/g"}, {"g.", "http://a/b/c/g."}, {".g", "http://a/b/c/.g"}, {"g..", "http://a/b/c/g.."}, {"..g", "http://a/b/c/..g"}, {"./../g", "http://a/b/g"}, {"./g/.", "http://a/b/c/g/"}, {"g/./h", "http://a/b/c/g/h"}, {"g/../h", "http://a/b/c/h"}, {"g;x=1/./y", "http://a/b/c/g;x=1/y"}, {"g;x=1/../y", "http://a/b/c/y"}, {"g?y/./x", "http://a/b/c/g?y/./x"}, {"g?y/../x", "http://a/b/c/g?y/../x"}, {"g#s/./x", "http://a/b/c/g#s/./x"}, {"g#s/../x", "http://a/b/c/g#s/../x"}, {"https://b/x", "https://b/x"}, }; for (const auto& [reference, expected] : cases) { const std::string resolved = base.Resolve(reference).ToString(); Check(resolved == expected, std::format("'{}' against the base resolves to {} (got {})", reference, expected, resolved)); } { const URL from = URL::Parse("http://127.0.0.1:8130/a/b/start"); Check(from.Resolve("../c/d").ToString() == "http://127.0.0.1:8130/a/c/d", "a port and a userinfo-free base keep their authority through a resolve"); Check(URL::Parse("http://u@h:81/p").Resolve("x").ToString() == "http://u@h:81/x", "the base's userinfo stays"); bool refused = false; try { from.Resolve("mailto:someone@example.com"); } catch (const std::invalid_argument&) { refused = true; } Check(refused, "a reference that resolves to no URL (mailto:) is refused"); } std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed"); return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; }