Make `Uri` treat `\` as `/` in path and authority.

When using `Uri.parse` or `Uri(path:..)`, a `\` is treated as, and converted to, a `/`.
This avoids a particular problematic difference in behavior between Dart and the browser's `URL` functionality. There are still examples where the two differ in interpretation of the same code, but in those cases, the Dart `Uri` will most likely end up without a host name, which should be easily detected.

Change-Id: I798df6c3c27c6d64fb9fc8dc30d90b06ba5a9004
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/258120
Reviewed-by: Nate Bosch <nbosch@google.com>
Reviewed-by: Michael Thomsen <mit@google.com>
Commit-Queue: Lasse Nielsen <lrn@google.com>
diff --git a/sdk/lib/core/uri.dart b/sdk/lib/core/uri.dart
index bb84862..3d27f1d 100644
--- a/sdk/lib/core/uri.dart
+++ b/sdk/lib/core/uri.dart
@@ -150,7 +150,8 @@
   /// [pathSegments].
   /// When [path] is used, it should be a valid URI path,
   /// but invalid characters, except the general delimiters ':/@[]?#',
-  /// will be escaped if necessary.
+  /// will be escaped if necessary. A backslash, `\`, will be converted
+  /// to a slash `/`.
   /// When [pathSegments] is used, each of the provided segments
   /// is first percent-encoded and then joined using the forward slash
   /// separator.
@@ -237,7 +238,7 @@
   ///
   /// The `path` component is set from the [unencodedPath]
   /// argument. The path passed must not be encoded as this constructor
-  /// encodes the path.
+  /// encodes the path. Only `/` is recognized as path separtor.
   /// If omitted, the path defaults to being empty.
   ///
   /// The `query` component is set from the optional [queryParameters]
@@ -997,6 +998,14 @@
         // If the port is empty, it should be omitted.
         // Pathological case, don't bother correcting it.
         isSimple = false;
+      } else if (uri.startsWith(r"\", pathStart) ||
+          hostStart > start &&
+              (uri.startsWith(r"\", hostStart - 1) ||
+                  uri.startsWith(r"\", hostStart - 2))) {
+        // Seeing a `\` anywhere.
+        // The scanner doesn't record when the first path character is a `\`
+        // or when the last slash before the authority is a `\`.
+        isSimple = false;
       } else if (queryStart < end &&
               (queryStart == pathStart + 2 &&
                   uri.startsWith("..", pathStart)) ||
@@ -2305,7 +2314,7 @@
       throw ArgumentError('Both path and pathSegments specified');
     } else {
       result = _normalizeOrSubstring(path, start, end, _pathCharOrSlashTable,
-          escapeDelimiters: true);
+          escapeDelimiters: true, replaceBackslash: true);
     }
     if (result.isEmpty) {
       if (isFile) return "/";
@@ -2322,7 +2331,10 @@
   /// "pure path" and normalization won't remove leading ".." segments.
   /// Otherwise it follows the RFC 3986 "remove dot segments" algorithm.
   static String _normalizePath(String path, String scheme, bool hasAuthority) {
-    if (scheme.isEmpty && !hasAuthority && !path.startsWith('/')) {
+    if (scheme.isEmpty &&
+        !hasAuthority &&
+        !path.startsWith('/') &&
+        !path.startsWith(r'\')) {
       return _normalizeRelativePath(path, scheme.isNotEmpty || hasAuthority);
     }
     return _removeDotSegments(path);
@@ -2451,9 +2463,10 @@
   /// this methods returns the substring if [component] from [start] to [end].
   static String _normalizeOrSubstring(
       String component, int start, int end, List<int> charTable,
-      {bool escapeDelimiters = false}) {
+      {bool escapeDelimiters = false, bool replaceBackslash = false}) {
     return _normalize(component, start, end, charTable,
-            escapeDelimiters: escapeDelimiters) ??
+            escapeDelimiters: escapeDelimiters,
+            replaceBackslash: replaceBackslash) ??
         component.substring(start, end);
   }
 
@@ -2468,7 +2481,7 @@
   /// Returns `null` if the original content was already normalized.
   static String? _normalize(
       String component, int start, int end, List<int> charTable,
-      {bool escapeDelimiters = false}) {
+      {bool escapeDelimiters = false, bool replaceBackslash = false}) {
     StringBuffer? buffer;
     int sectionStart = start;
     int index = start;
@@ -2494,6 +2507,9 @@
           } else {
             sourceLength = 3;
           }
+        } else if (char == _BACKSLASH && replaceBackslash) {
+          replacement = "/";
+          sourceLength = 1;
         } else if (!escapeDelimiters && _isGeneralDelimiter(char)) {
           _fail(component, index, "Invalid character");
           throw "unreachable"; // TODO(lrn): Remove when Never-returning functions are recognized as throwing.
@@ -4197,6 +4213,7 @@
   setChars(b, ".", schemeOrPathDot);
   setChars(b, ":", authOrPath | schemeEnd); // Handle later.
   setChars(b, "/", authOrPathSlash);
+  setChars(b, r"\", authOrPathSlash | notSimple);
   setChars(b, "?", query | queryStart);
   setChars(b, "#", fragment | fragmentStart);
 
@@ -4204,7 +4221,7 @@
   setChars(b, pchar, schemeOrPath);
   setChars(b, ".", schemeOrPathDot2);
   setChars(b, ':', authOrPath | schemeEnd);
-  setChars(b, "/", pathSeg | notSimple);
+  setChars(b, r"/\", pathSeg | notSimple);
   setChars(b, "?", query | queryStart);
   setChars(b, "#", fragment | fragmentStart);
 
@@ -4213,6 +4230,7 @@
   setChars(b, "%", schemeOrPath | notSimple);
   setChars(b, ':', authOrPath | schemeEnd);
   setChars(b, "/", relPathSeg);
+  setChars(b, r"\", relPathSeg | notSimple);
   setChars(b, "?", query | queryStart);
   setChars(b, "#", fragment | fragmentStart);
 
@@ -4220,12 +4238,14 @@
   setChars(b, pchar, schemeOrPath);
   setChars(b, ':', authOrPath | schemeEnd);
   setChars(b, "/", pathSeg);
+  setChars(b, r"\", pathSeg | notSimple);
   setChars(b, "?", query | queryStart);
   setChars(b, "#", fragment | fragmentStart);
 
   b = build(authOrPath, path | notSimple);
   setChars(b, pchar, path | pathStart);
   setChars(b, "/", authOrPathSlash | pathStart);
+  setChars(b, r"\", authOrPathSlash | pathStart); // This should be non-simple.
   setChars(b, ".", pathSegDot | pathStart);
   setChars(b, "?", query | queryStart);
   setChars(b, "#", fragment | fragmentStart);
@@ -4233,6 +4253,7 @@
   b = build(authOrPathSlash, path | notSimple);
   setChars(b, pchar, path);
   setChars(b, "/", uinfoOrHost0 | hostStart);
+  setChars(b, r"\", uinfoOrHost0 | hostStart); // This should be non-simple.
   setChars(b, ".", pathSegDot);
   setChars(b, "?", query | queryStart);
   setChars(b, "#", fragment | fragmentStart);
@@ -4244,6 +4265,7 @@
   setChars(b, "@", uinfoOrHost0 | hostStart);
   setChars(b, "[", ipv6Host | notSimple);
   setChars(b, "/", pathSeg | pathStart);
+  setChars(b, r"\", pathSeg | pathStart); // This should be non-simple.
   setChars(b, "?", query | queryStart);
   setChars(b, "#", fragment | fragmentStart);
 
@@ -4253,6 +4275,7 @@
   setChars(b, ":", uinfoOrPort0 | portStart);
   setChars(b, "@", uinfoOrHost0 | hostStart);
   setChars(b, "/", pathSeg | pathStart);
+  setChars(b, r"\", pathSeg | pathStart); // This should be non-simple.
   setChars(b, "?", query | queryStart);
   setChars(b, "#", fragment | fragmentStart);
 
@@ -4260,6 +4283,7 @@
   setRange(b, "19", uinfoOrPort);
   setChars(b, "@", uinfoOrHost0 | hostStart);
   setChars(b, "/", pathSeg | pathStart);
+  setChars(b, r"\", pathSeg | pathStart); // This should be non-simple.
   setChars(b, "?", query | queryStart);
   setChars(b, "#", fragment | fragmentStart);
 
@@ -4267,6 +4291,7 @@
   setRange(b, "09", uinfoOrPort);
   setChars(b, "@", uinfoOrHost0 | hostStart);
   setChars(b, "/", pathSeg | pathStart);
+  setChars(b, r"\", pathSeg | pathStart); // This should be non-simple.
   setChars(b, "?", query | queryStart);
   setChars(b, "#", fragment | fragmentStart);
 
@@ -4276,46 +4301,48 @@
   b = build(relPathSeg, path | notSimple);
   setChars(b, pchar, path);
   setChars(b, ".", relPathSegDot);
-  setChars(b, "/", pathSeg | notSimple);
+  setChars(b, r"/\", pathSeg | notSimple);
   setChars(b, "?", query | queryStart);
   setChars(b, "#", fragment | fragmentStart);
 
   b = build(relPathSegDot, path | notSimple);
   setChars(b, pchar, path);
   setChars(b, ".", relPathSegDot2);
-  setChars(b, "/", pathSeg | notSimple);
+  setChars(b, r"/\", pathSeg | notSimple);
   setChars(b, "?", query | queryStart);
   setChars(b, "#", fragment | fragmentStart);
 
   b = build(relPathSegDot2, path | notSimple);
   setChars(b, pchar, path);
   setChars(b, "/", relPathSeg);
+  setChars(b, r"\", relPathSeg | notSimple);
   setChars(b, "?", query | queryStart); // This should be non-simple.
   setChars(b, "#", fragment | fragmentStart); // This should be non-simple.
 
   b = build(pathSeg, path | notSimple);
   setChars(b, pchar, path);
   setChars(b, ".", pathSegDot);
-  setChars(b, "/", pathSeg | notSimple);
+  setChars(b, r"/\", pathSeg | notSimple);
   setChars(b, "?", query | queryStart);
   setChars(b, "#", fragment | fragmentStart);
 
   b = build(pathSegDot, path | notSimple);
   setChars(b, pchar, path);
   setChars(b, ".", pathSegDot2);
-  setChars(b, "/", pathSeg | notSimple);
+  setChars(b, r"/\", pathSeg | notSimple);
   setChars(b, "?", query | queryStart);
   setChars(b, "#", fragment | fragmentStart);
 
   b = build(pathSegDot2, path | notSimple);
   setChars(b, pchar, path);
-  setChars(b, "/", pathSeg | notSimple);
+  setChars(b, r"/\", pathSeg | notSimple);
   setChars(b, "?", query | queryStart);
   setChars(b, "#", fragment | fragmentStart);
 
   b = build(path, path | notSimple);
   setChars(b, pchar, path);
   setChars(b, "/", pathSeg);
+  setChars(b, r"/\", pathSeg | notSimple);
   setChars(b, "?", query | queryStart);
   setChars(b, "#", fragment | fragmentStart);
 
diff --git a/tests/corelib/uri_test.dart b/tests/corelib/uri_test.dart
index dcddc9e..a1310c7 100644
--- a/tests/corelib/uri_test.dart
+++ b/tests/corelib/uri_test.dart
@@ -573,6 +573,81 @@
       uri.resolve("/qux").toString());
 }
 
+void testBackslashes() {
+  // Tests change which makes `\` be treated as `/` in
+  // autority and path.
+
+  Expect.stringEquals("https://example.com/",
+      Uri.parse(r"https:\\example.com\").toString());
+
+  Expect.stringEquals("https://example.com/",
+      Uri.parse(r"https:\/example.com/").toString());
+  Expect.stringEquals("https://example.com/",
+      Uri.parse(r"https:/\example.com/").toString());
+  Expect.stringEquals("https://example.com/",
+      Uri.parse(r"https://example.com/").toString());
+  Expect.stringEquals("https://example.com/foo//bar",
+      Uri.parse(r"https://example.com/foo\\bar").toString());
+
+  Expect.stringEquals("https:/example.com/foo?%5C#%5C",
+      Uri.parse(r"https:\example.com/foo?\#\").toString());
+
+  Expect.stringEquals("https://example.com/@example.net/foo",
+      Uri.parse(r"https://example.com\@example.net/foo").toString());
+
+  Expect.stringEquals("file:///foo",
+      Uri.parse(r"file:foo").toString());
+  Expect.stringEquals("file:///foo",
+      Uri.parse(r"file:\foo").toString());
+  Expect.stringEquals("file://foo/",
+      Uri.parse(r"file:\\foo").toString());
+  Expect.stringEquals("file:///foo",
+      Uri.parse(r"file:\\\foo").toString());
+  Expect.stringEquals("file:///foo",
+      Uri.parse(r"file:\//foo").toString());
+  Expect.stringEquals("file:///foo",
+      Uri.parse(r"file:/\/foo").toString());
+  Expect.stringEquals("file:///foo",
+      Uri.parse(r"file://\foo").toString());
+
+  // No scheme.
+  Expect.stringEquals("//example.com/foo",
+      Uri.parse(r"\\example.com\foo").toString());
+
+  // No authority.
+  Expect.stringEquals("http:/foo",
+      Uri.parse(r"http:\foo").toString());
+
+  /// No scheme or authority.
+
+  Expect.stringEquals("foo/bar/baz",
+      Uri.parse(r"foo\bar\baz").toString());
+
+  Expect.stringEquals("foo/bar/baz",
+      Uri.parse(r"foo\bar\.\baz").toString());
+
+  Expect.stringEquals("foo/baz",
+      Uri.parse(r"foo\bar\..\baz").toString());
+
+  // Not converted to / in query or fragment, still escaped.
+  Expect.stringEquals("https://example.com/foo?%5C#%5C",
+      Uri.parse(r"https://example.com/foo?\#\").toString());
+
+  // Applies when a path is provided, but not when using path segments.
+  Expect.stringEquals("https://example.com/foo/bar",
+    Uri(scheme: "https", host: "example.com", path: r"\foo\bar").toString());
+
+  Expect.stringEquals("https://example.com/foo%5Cbar",
+    Uri(scheme: "https", host: "example.com", pathSegments: [r"foo\bar"])
+        .toString());
+
+  // Does not apply to constructors which expect an unencoded path.
+  Expect.stringEquals("http://example.com/%5Cfoo%5Cbar",
+      Uri.http("example.com", r"\foo\bar").toString());
+  Expect.stringEquals("https://example.com/%5Cfoo%5Cbar",
+      Uri.https("example.com", r"\foo\bar").toString());
+}
+
 main() {
   testUri("http:", true);
   testUri("file:///", true);
@@ -725,6 +800,7 @@
   testNormalization();
   testReplace();
   testPackageUris();
+  testBackslashes();
 }
 
 String dump(Uri uri) {
diff --git a/tests/corelib_2/uri_test.dart b/tests/corelib_2/uri_test.dart
index 3ac87ea..c78b483 100644
--- a/tests/corelib_2/uri_test.dart
+++ b/tests/corelib_2/uri_test.dart
@@ -575,6 +575,81 @@
       uri.resolve("/qux").toString());
 }
 
+void testBackslashes() {
+  // Tests change which makes `\` be treated as `/` in
+  // autority and path.
+
+  Expect.stringEquals("https://example.com/",
+      Uri.parse(r"https:\\example.com\").toString());
+
+  Expect.stringEquals("https://example.com/",
+      Uri.parse(r"https:\/example.com/").toString());
+  Expect.stringEquals("https://example.com/",
+      Uri.parse(r"https:/\example.com/").toString());
+  Expect.stringEquals("https://example.com/",
+      Uri.parse(r"https://example.com/").toString());
+  Expect.stringEquals("https://example.com/foo//bar",
+      Uri.parse(r"https://example.com/foo\\bar").toString());
+
+  Expect.stringEquals("https:/example.com/foo?%5C#%5C",
+      Uri.parse(r"https:\example.com/foo?\#\").toString());
+
+  Expect.stringEquals("https://example.com/@example.net/foo",
+      Uri.parse(r"https://example.com\@example.net/foo").toString());
+
+  Expect.stringEquals("file:///foo",
+      Uri.parse(r"file:foo").toString());
+  Expect.stringEquals("file:///foo",
+      Uri.parse(r"file:\foo").toString());
+  Expect.stringEquals("file://foo/",
+      Uri.parse(r"file:\\foo").toString());
+  Expect.stringEquals("file:///foo",
+      Uri.parse(r"file:\\\foo").toString());
+  Expect.stringEquals("file:///foo",
+      Uri.parse(r"file:\//foo").toString());
+  Expect.stringEquals("file:///foo",
+      Uri.parse(r"file:/\/foo").toString());
+  Expect.stringEquals("file:///foo",
+      Uri.parse(r"file://\foo").toString());
+
+  // No scheme.
+  Expect.stringEquals("//example.com/foo",
+      Uri.parse(r"\\example.com\foo").toString());
+
+  // No authority.
+  Expect.stringEquals("http:/foo",
+      Uri.parse(r"http:\foo").toString());
+
+  /// No scheme or authority.
+
+  Expect.stringEquals("foo/bar/baz",
+      Uri.parse(r"foo\bar\baz").toString());
+
+  Expect.stringEquals("foo/bar/baz",
+      Uri.parse(r"foo\bar\.\baz").toString());
+
+  Expect.stringEquals("foo/baz",
+      Uri.parse(r"foo\bar\..\baz").toString());
+
+  // Not converted to / in query or fragment, still escaped.
+  Expect.stringEquals("https://example.com/foo?%5C#%5C",
+      Uri.parse(r"https://example.com/foo?\#\").toString());
+
+  // Applies when a path is provided, but not when using path segments.
+  Expect.stringEquals("https://example.com/foo/bar",
+    Uri(scheme: "https", host: "example.com", path: r"\foo\bar").toString());
+
+  Expect.stringEquals("https://example.com/foo%5Cbar",
+    Uri(scheme: "https", host: "example.com", pathSegments: [r"foo\bar"])
+        .toString());
+
+  // Does not apply to constructors which expect an unencoded path.
+  Expect.stringEquals("http://example.com/%5Cfoo%5Cbar",
+      Uri.http("example.com", r"\foo\bar").toString());
+  Expect.stringEquals("https://example.com/%5Cfoo%5Cbar",
+      Uri.https("example.com", r"\foo\bar").toString());
+}
+
 main() {
   testUri("http:", true);
   testUri("file:///", true);
@@ -727,6 +802,7 @@
   testNormalization();
   testReplace();
   testPackageUris();
+  testBackslashes();
 }
 
 String dump(Uri uri) {