feat(runfiles): add support for spaces and newlines in runfiles paths (#2456)

Bazel 7.4.0 introduced support for all characters in runfile source and
target paths: https://github.com/bazelbuild/bazel/pull/23912

This is a backwards-compatible change, based on a similar change in
rules_go: https://github.com/bazel-contrib/rules_go/pull/4136
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a20057e..cc66afa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -112,6 +112,7 @@
 * (providers) Added {obj}`py_runtime_info.site_init_template` and
   {obj}`PyRuntimeInfo.site_init_template` for specifying the template to use to
   initialize the interpreter via venv startup hooks.
+* (runfiles) (Bazel 7.4+) Added support for spaces and newlines in runfiles paths
 
 {#v0-0-0-removed}
 ### Removed
diff --git a/python/runfiles/runfiles.py b/python/runfiles/runfiles.py
index 6d47d24..ea816c6 100644
--- a/python/runfiles/runfiles.py
+++ b/python/runfiles/runfiles.py
@@ -58,13 +58,24 @@
         result = {}
         with open(path, "r") as f:
             for line in f:
-                line = line.strip()
-                if line:
-                    tokens = line.split(" ", 1)
-                    if len(tokens) == 1:
-                        result[line] = line
-                    else:
-                        result[tokens[0]] = tokens[1]
+                line = line.rstrip("\n")
+                if line.startswith(" "):
+                    # In lines that start with a space, spaces, newlines, and backslashes are escaped as \s, \n, and \b in
+                    # link and newlines and backslashes are escaped in target.
+                    escaped_link, escaped_target = line[1:].split(" ", maxsplit=1)
+                    link = (
+                        escaped_link.replace(r"\s", " ")
+                        .replace(r"\n", "\n")
+                        .replace(r"\b", "\\")
+                    )
+                    target = escaped_target.replace(r"\n", "\n").replace(r"\b", "\\")
+                else:
+                    link, target = line.split(" ", maxsplit=1)
+
+                if target:
+                    result[link] = target
+                else:
+                    result[link] = link
         return result
 
     def _GetRunfilesDir(self) -> str:
diff --git a/tests/runfiles/runfiles_test.py b/tests/runfiles/runfiles_test.py
index 03350f3..cf6a70a 100644
--- a/tests/runfiles/runfiles_test.py
+++ b/tests/runfiles/runfiles_test.py
@@ -185,10 +185,11 @@
     def testManifestBasedRlocation(self) -> None:
         with _MockFile(
             contents=[
-                "Foo/runfile1",
+                "Foo/runfile1 ",  # A trailing whitespace is always present in single entry lines.
                 "Foo/runfile2 C:/Actual Path\\runfile2",
                 "Foo/Bar/runfile3 D:\\the path\\run file 3.txt",
                 "Foo/Bar/Dir E:\\Actual Path\\Directory",
+                " Foo\\sBar\\bDir\\nNewline/runfile5 F:\\bActual Path\\bwith\\nnewline/runfile5",
             ]
         ) as mf:
             r = runfiles.CreateManifestBased(mf.Path())
@@ -205,6 +206,10 @@
                 r.Rlocation("Foo/Bar/Dir/Deeply/Nested/runfile4"),
                 "E:\\Actual Path\\Directory/Deeply/Nested/runfile4",
             )
+            self.assertEqual(
+                r.Rlocation("Foo Bar\\Dir\nNewline/runfile5"),
+                "F:\\Actual Path\\with\nnewline/runfile5",
+            )
             self.assertIsNone(r.Rlocation("unknown"))
             if RunfilesTest.IsWindows():
                 self.assertEqual(r.Rlocation("c:/foo"), "c:/foo")