Repository: bazelbuild/bazel
Issue: Implement "Locating runfiles with Bzlmod" proposal
Labels: type: feature request
P2
team-ExternalDeps
area-Bzlmod

The ["Locating runfiles with Bzlmod" proposal](https://github.com/bazelbuild/proposals/blob/main/designs/2022-07-21-locating-runfiles-with-bzlmod.md) consists of the following individual steps:

* [x] ~Add the `RunfilesLibraryInfo` provider (#16125)~
* [x] Collect repository names and mappings of the transitive closure of a target (#16278)
* [x] Emit a repository mapping manifest for every executable (#16321 and https://github.com/bazelbuild/bazel/pull/16652)
* [x] Revert `RunfilesLibraryInfo`
* [x] Expose the current repository name in rules
  * [x] Bash (#16693)
  * [x] C++ (#16216)
  * [x] Java (#16534)
  * [x] Python (#16341)
* [x] Parse and use the repository mapping manifest in runfiles libraries (https://github.com/bazelbuild/bazel/issues/15029)
  * [x] Bash (#16693)
  * [x] C++ (#16623 and https://github.com/bazelbuild/bazel/pull/16701)
  * [x] Java (#16549)
  * [X] ~Python~ has been updated in rules_python
* [x] Use the Java runfiles library to make Stardoc work with repository mappings (https://github.com/bazelbuild/bazel/issues/14140)

Status of runfiles libraries in third-party rulesets:

* [x] rules_go (https://github.com/bazelbuild/rules_go/pull/3347)
* [ ] [rules_rust](https://github.com/bazelbuild/rules_rust/blob/main/tools/runfiles/runfiles.rs) (???)
* [ ] [rules_haskell](https://github.com/tweag/rules_haskell/blob/master/tools/runfiles/README.md) (???)

Issue comments:
@Wyverald Could you assign me and add the appropriate labels?
~~@comius Do you expect `java_binary` to be starlarkified in time for Bazel 6? That would simplify the changes to the Java rules.~~ Most likely no longer necessary, as the changes will apply to `buildjar` itself.
@Wyverald Oops, I missed the use of `Closes ...` in the PR description - we should use `Work towards ...` instead. Could you reopen? For some I lack the permission to do so.
It's the "To fix #NNN" line... GitHub trying to be clever here
The most likely future seems to be that we'll standardize on Python imports _not_ containing the repository name, in which case bzlmod is a no-op.

It's not an optimal solution because it requires an entry in `$PYTHONPATH` for each repository in the transitive closure of the Python binary, but it's the status quo and I'd much prefer thinking up a solution to that (e.g. binary-specific symlink trees that do not look like runfiles trees, dunno) than to standardize on having the repository names in imports because the concept of "repository" is specific to Bazel.

Put differently: the only way for bzlmod to cause trouble for Python is if we decide to require repository names in imports, which seems to be unlikely and it looks like the decision on that will take a while and I prefer not blocking either Bazel 6.0 or the removal of bzlmod from the experimental domain to avoid an unlikely failure mode. And should that happen, we can always paper over it with some sort of import hook.

/cc @rickeylev 
@oquenchil I'm thinking about whether we should use a macro to pass `BAZEL_CURRENT_REPOSITORY` to the `Rlocation` function automatically (e.g. do `#define Rlocation(path) RlocationReal(path, BAZEL_CURRENT_REPOSITORY)`). Do you think that would be too intrusive/magical?
Would users have to write that line or is it part of a file that is automatically generated?

I don't mind adding whatever will make it easier but if it gets too magical it should maybe be wrapped so that the details aren't visible to the user unless they want to look.
> Would users have to write that line or is it part of a file that is automatically generated?
> 
> I don't mind adding whatever will make it easier but if it gets too magical it should maybe be wrapped so that the details aren't visible to the user unless they want to look.

@oquenchil I would want to make that magic part of the runfiles library header so that users don't have to write it themselves.

What I am worried about is having an `Rlocation` macro mess with people's existing codebase since there doesn't seem to be a way to put it in a namespace or restrict it to member functions only.
Aren't we in control of the name of the macro? Can we rename it to something that would be very unlikely to clash with anything?
> Aren't we in control of the name of the macro? Can we rename it to something that would be very unlikely to clash with anything?

We can call it something else, we just won't be able to have existing C++ codebases use the new repo mapping-aware lookup automatically if we do so. But of course having to replace all `runfiles::create()` calls with something else won't be too bad.
If the macro name is Rlocation and that may start clashing with people's code in C++ files then I think it would be prudent to rename it if we decide to go with the macro.

I guess the call to make here is how much of a breaking change this is compared to the benefit of passing BAZEL_CURRENT_REPOSITORY automatically for users. I would say that the benefit is not too great but as far as I understood the use of Bazel runfiles in C++ code wasn't too wide-spread either?

>  But of course having to replace all runfiles::create() calls with something else won't be too bad.

How do you know? Is it based on search results from GH? If a release note would be enough for it not to be too disruptive then let's rename it.
I have experimented a bit more with APIs while working on the corresponding change for Java (https://github.com/bazelbuild/bazel/pull/16549).

What I now like more than the macro approach is to have users (optionally) pass in a canonical repository name when creating the `Runfiles` instance and default to that repo in `Rlocation` calls, while providing ways to override the default with an overload of `Rlocation` that accepts a repository name as a second parameter. In this way, users only have to pass in `BAZEL_CURRENT_REPOSITORY` only once in the common case, which should render a macro unnecessary.
Update: All changes relevant for Bazel 6.0.0 are now in review.
This is mostly done now, with only the bugfix https://github.com/bazelbuild/bazel/pull/16755 and a number of cherry-picks to 6.0.0 missing.
@Wyverald You can remove this from the "6.0.0 nice to have" milestone as the only remaining issue is not tied to a Bazel release (Stardoc).
Downgrading because the most important points have been addressed; the only outstanding issue is Stardoc, which is undergoing a rewrite currently by @tetromino.

Pull request review comments:

Patch that resolved the issue:
diff --git a/tools/java/runfiles/Runfiles.java b/tools/java/runfiles/Runfiles.java
index 9a3aa26c4517a9..171d1b2078ade4 100644
--- a/tools/java/runfiles/Runfiles.java
+++ b/tools/java/runfiles/Runfiles.java
@@ -17,12 +17,16 @@
 import java.io.BufferedReader;
 import java.io.File;
 import java.io.FileInputStream;
+import java.io.FileReader;
 import java.io.IOException;
 import java.io.InputStreamReader;
+import java.lang.ref.SoftReference;
 import java.nio.charset.StandardCharsets;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
 
 /**
  * Runfiles lookup library for Bazel-built Java binaries and tests.
@@ -45,15 +49,58 @@
  *   import com.google.devtools.build.runfiles.Runfiles;
  * </pre>
  *
- * <p>3. Create a Runfiles object and use rlocation to look up runfile paths:
+ * <p>3. Create a {@link Preloaded} object:
  *
  * <pre>
  *   public void myFunction() {
- *     Runfiles runfiles = Runfiles.create();
- *     String path = runfiles.rlocation("my_workspace/path/to/my/data.txt");
+ *     Runfiles.Preloaded runfiles = Runfiles.preload();
  *     ...
  * </pre>
  *
+ * <p>4. To look up a runfile, use either of the following approaches:
+ *
+ * <p>4a. Annotate the class from which runfiles should be looked up with {@link
+ * AutoBazelRepository} and obtain the name of the Bazel repository containing the class from a
+ * constant generated by this annotation:
+ *
+ * <pre>
+ *   import com.google.devtools.build.runfiles.AutoBazelRepository;
+ *   &#64;AutoBazelRepository
+ *   public class MyClass {
+ *     public void myFunction() {
+ *       Runfiles.Preloaded runfiles = Runfiles.preload();
+ *       String path = runfiles.withSourceRepository(AutoBazelRepository_MyClass.NAME)
+ *                             .rlocation("my_workspace/path/to/my/data.txt");
+ *       ...
+ *
+ * </pre>
+ *
+ * <p>4b. Let Bazel compute the path passed to rlocation and pass it into a <code>java_binary</code>
+ * via an argument or an environment variable:
+ *
+ * <pre>
+ *   java_binary(
+ *       name = "my_binary",
+ *       srcs = ["MyClass.java"],
+ *       data = ["@my_workspace//path/to/my:data.txt"],
+ *       env = {"MY_RUNFILE": "$(rlocationpath @my_workspace//path/to/my:data.txt)"},
+ *   )
+ * </pre>
+ *
+ * <pre>
+ *   public class MyClass {
+ *     public void myFunction() {
+ *       Runfiles.Preloaded runfiles = Runfiles.preload();
+ *       String path = runfiles.unmapped().rlocation(System.getenv("MY_RUNFILE"));
+ *       ...
+ *
+ * </pre>
+ *
+ * For more details on why it is required to pass in the current repository name, see {@see
+ * https://bazel.build/build/bzlmod#repository-names}.
+ *
+ * <h3>Subprocesses</h3>
+ *
  * <p>If you want to start subprocesses that also need runfiles, you need to set the right
  * environment variables for them:
  *
@@ -64,23 +111,135 @@
  *   ...
  *   Process p = pb.start();
  * </pre>
+ *
+ * <h3>{@link Preloaded} vs. {@link Runfiles}</h3>
+ *
+ * <p>Instances of {@link Preloaded} are meant to be stored and passed around to other components
+ * that need to access runfiles. They are created by calling {@link Runfiles#preload()} {@link
+ * Runfiles#preload(Map)} and immutably encapsulate all data required to look up runfiles with the
+ * repository mapping of any Bazel repository specified at a later time.
+ *
+ * <p>Creating {@link Runfiles} instances can be costly, so applications should try to create as few
+ * instances as possible. {@link Runfiles#preload()}, but not {@link Runfiles#preload(Map)}, returns
+ * a single global, softly cached instance of {@link Preloaded} that is constructed based on the
+ * JVM's environment variables.
+ *
+ * <p>Instance of {@link Runfiles} are only meant to be used by code located in a single Bazel
+ * repository and should not be passed around. They are created by calling {@link
+ * Preloaded#withSourceRepository(String)} or {@link Preloaded#unmapped()} and in addition to the
+ * data in {@link Preloaded} also fix a source repository relative to which apparent repository
+ * names are resolved.
+ *
+ * <p>Creating {@link Preloaded} instances is cheap.
  */
-public abstract class Runfiles {
+public final class Runfiles {
+
+  /**
+   * A class that encapsulates all data required to look up runfiles relative to any Bazel
+   * repository fixed at a later time.
+   *
+   * <p>This class is immutable.
+   */
+  public abstract static class Preloaded {
+
+    /** See {@link com.google.devtools.build.lib.analysis.RepoMappingManifestAction.Entry}. */
+    static class RepoMappingKey {
+
+      public final String sourceRepo;
+      public final String targetRepoApparentName;
+
+      public RepoMappingKey(String sourceRepo, String targetRepoApparentName) {
+        this.sourceRepo = sourceRepo;
+        this.targetRepoApparentName = targetRepoApparentName;
+      }
+
+      @Override
+      public boolean equals(Object o) {
+        if (this == o) {
+          return true;
+        }
+        if (o == null || !(o instanceof RepoMappingKey)) {
+          return false;
+        }
+        RepoMappingKey that = (RepoMappingKey) o;
+        return sourceRepo.equals(that.sourceRepo)
+            && targetRepoApparentName.equals(that.targetRepoApparentName);
+      }
+
+      @Override
+      public int hashCode() {
+        return Objects.hash(sourceRepo, targetRepoApparentName);
+      }
+    }
+
+    /**
+     * Returns a {@link Runfiles} instance that uses the provided source repository's repository
+     * mapping to translate apparent into canonical repository names.
+     *
+     * <p>{@see https://bazel.build/build/bzlmod#repository-names}
+     *
+     * @param sourceRepository the canonical name of the Bazel repository relative to which apparent
+     *     repository names should be resolved. Should generally coincide with the Bazel repository
+     *     that contains the caller of this method, which can be obtained via {@link
+     *     AutoBazelRepository}.
+     * @return a {@link Runfiles} instance that looks up runfiles relative to the provided source
+     *     repository and shares all other data with this {@link Preloaded} instance.
+     */
+    public final Runfiles withSourceRepository(String sourceRepository) {
+      Util.checkArgument(sourceRepository != null);
+      return new Runfiles(this, sourceRepository);
+    }
+
+    /**
+     * Returns a {@link Runfiles} instance backed by the preloaded runfiles data that can be used to
+     * look up runfiles paths with canonical repository names only.
+     *
+     * @return a {@link Runfiles} instance that can only look up paths with canonical repository
+     *     names and shared all data with this {@link Preloaded} instance.
+     */
+    public final Runfiles unmapped() {
+      return new Runfiles(this, null);
+    }
 
-  // Package-private constructor, so only package-private classes may extend it.
-  private Runfiles() {}
+    protected abstract Map<String, String> getEnvVars();
+
+    protected abstract String rlocationChecked(String path);
+
+    protected abstract Map<RepoMappingKey, String> getRepoMapping();
+
+    // Private constructor, so only nested classes may extend it.
+    private Preloaded() {}
+  }
+
+  private static final String MAIN_REPOSITORY = "";
+
+  private static SoftReference<Preloaded> defaultInstance = new SoftReference<>(null);
+
+  private final Preloaded preloadedRunfiles;
+  private final String sourceRepository;
+
+  private Runfiles(Preloaded preloadedRunfiles, String sourceRepository) {
+    this.preloadedRunfiles = preloadedRunfiles;
+    this.sourceRepository = sourceRepository;
+  }
 
   /**
-   * Returns a new {@link Runfiles} instance.
+   * Returns the softly cached global {@link Runfiles.Preloaded} instance, creating it if needed.
    *
    * <p>This method passes the JVM's environment variable map to {@link #create(Map)}.
    */
-  public static Runfiles create() throws IOException {
-    return create(System.getenv());
+  public static synchronized Preloaded preload() throws IOException {
+    Preloaded instance = defaultInstance.get();
+    if (instance != null) {
+      return instance;
+    }
+    instance = preload(System.getenv());
+    defaultInstance = new SoftReference<>(instance);
+    return instance;
   }
 
   /**
-   * Returns a new {@link Runfiles} instance.
+   * Returns a new {@link Runfiles.Preloaded} instance.
    *
    * <p>The returned object is either:
    *
@@ -104,7 +263,7 @@ public static Runfiles create() throws IOException {
    *     "RUNFILES_MANIFEST_FILE", "RUNFILES_DIR", or "JAVA_RUNFILES" key in {@code env} or their
    *     values are empty, or some IO error occurs
    */
-  public static Runfiles create(Map<String, String> env) throws IOException {
+  public static Preloaded preload(Map<String, String> env) throws IOException {
     if (isManifestOnly(env)) {
       // On Windows, Bazel sets RUNFILES_MANIFEST_ONLY=1.
       // On every platform, Bazel also sets RUNFILES_MANIFEST_FILE, but on Linux and macOS it's
@@ -115,6 +274,51 @@ public static Runfiles create(Map<String, String> env) throws IOException {
     }
   }
 
+  /**
+   * Returns a new {@link Runfiles} instance.
+   *
+   * <p>This method passes the JVM's environment variable map to {@link #create(Map)}.
+   *
+   * @deprecated Use {@link #preload()} instead. With {@code --enable_bzlmod}, this function does
+   *     not work correctly.
+   */
+  @Deprecated
+  public static Runfiles create() throws IOException {
+    return preload().withSourceRepository(MAIN_REPOSITORY);
+  }
+
+  /**
+   * Returns a new {@link Runfiles} instance.
+   *
+   * <p>The returned object is either:
+   *
+   * <ul>
+   *   <li>manifest-based, meaning it looks up runfile paths from a manifest file, or
+   *   <li>directory-based, meaning it looks up runfile paths under a given directory path
+   * </ul>
+   *
+   * <p>If {@code env} contains "RUNFILES_MANIFEST_ONLY" with value "1", this method returns a
+   * manifest-based implementation. The manifest's path is defined by the "RUNFILES_MANIFEST_FILE"
+   * key's value in {@code env}.
+   *
+   * <p>Otherwise this method returns a directory-based implementation. The directory's path is
+   * defined by the value in {@code env} under the "RUNFILES_DIR" key, or if absent, then under the
+   * "JAVA_RUNFILES" key.
+   *
+   * <p>Note about performance: the manifest-based implementation eagerly reads and caches the whole
+   * manifest file upon instantiation.
+   *
+   * @throws IOException if RUNFILES_MANIFEST_ONLY=1 is in {@code env} but there's no
+   *     "RUNFILES_MANIFEST_FILE", "RUNFILES_DIR", or "JAVA_RUNFILES" key in {@code env} or their
+   *     values are empty, or some IO error occurs
+   * @deprecated Use {@link #preload(Map)} instead. With {@code --enable_bzlmod}, this function does
+   *     not work correctly.
+   */
+  @Deprecated
+  public static Runfiles create(Map<String, String> env) throws IOException {
+    return preload(env).withSourceRepository(MAIN_REPOSITORY);
+  }
+
   /**
    * Returns the runtime path of a runfile (a Bazel-built binary's/test's data-dependency).
    *
@@ -128,7 +332,7 @@ public static Runfiles create(Map<String, String> env) throws IOException {
    * @throws IllegalArgumentException if {@code path} fails validation, for example if it's null or
    *     empty, or not normalized (contains "./", "../", or "//")
    */
-  public final String rlocation(String path) {
+  public String rlocation(String path) {
     Util.checkArgument(path != null);
     Util.checkArgument(!path.isEmpty());
     Util.checkArgument(
@@ -145,7 +349,22 @@ public final String rlocation(String path) {
     if (new File(path).isAbsolute()) {
       return path;
     }
-    return rlocationChecked(path);
+
+    if (sourceRepository == null) {
+      return preloadedRunfiles.rlocationChecked(path);
+    }
+    String[] apparentTargetAndRemainder = path.split("/", 2);
+    if (apparentTargetAndRemainder.length < 2) {
+      return preloadedRunfiles.rlocationChecked(path);
+    }
+    String targetCanonical =
+        preloadedRunfiles
+            .getRepoMapping()
+            .getOrDefault(
+                new Preloaded.RepoMappingKey(sourceRepository, apparentTargetAndRemainder[0]),
+                apparentTargetAndRemainder[0]);
+    return preloadedRunfiles.rlocationChecked(
+        targetCanonical + "/" + apparentTargetAndRemainder[1]);
   }
 
   /**
@@ -154,7 +373,9 @@ public final String rlocation(String path) {
    * <p>The caller should add the returned key-value pairs to the environment of subprocesses in
    * case those subprocesses are also Bazel-built binaries that need to use runfiles.
    */
-  public abstract Map<String, String> getEnvVars();
+  public Map<String, String> getEnvVars() {
+    return preloadedRunfiles.getEnvVars();
+  }
 
   /** Returns true if the platform supports runfiles only via manifests. */
   private static boolean isManifestOnly(Map<String, String> env) {
@@ -183,50 +404,49 @@ private static String getRunfilesDir(Map<String, String> env) throws IOException
     return value;
   }
 
-  abstract String rlocationChecked(String path);
+  private static Map<Preloaded.RepoMappingKey, String> loadRepositoryMapping(String path)
+      throws IOException {
+    if (path == null || !new File(path).exists()) {
+      return Collections.emptyMap();
+    }
+
+    try (BufferedReader r = new BufferedReader(new FileReader(path, StandardCharsets.UTF_8))) {
+      return Collections.unmodifiableMap(
+          r.lines()
+              .filter(line -> !line.isEmpty())
+              .map(
+                  line -> {
+                    String[] split = line.split(",");
+                    if (split.length != 3) {
+                      throw new IllegalArgumentException(
+                          "Invalid line in repository mapping: '" + line + "'");
+                    }
+                    return split;
+                  })
+              .collect(
+                  Collectors.toMap(
+                      split -> new Preloaded.RepoMappingKey(split[0], split[1]),
+                      split -> split[2])));
+    }
+  }
 
   /** {@link Runfiles} implementation that parses a runfiles-manifest file to look up runfiles. */
-  private static final class ManifestBased extends Runfiles {
+  private static final class ManifestBased extends Runfiles.Preloaded {
+
     private final Map<String, String> runfiles;
     private final String manifestPath;
+    private final Map<RepoMappingKey, String> repoMapping;
 
     ManifestBased(String manifestPath) throws IOException {
       Util.checkArgument(manifestPath != null);
       Util.checkArgument(!manifestPath.isEmpty());
       this.manifestPath = manifestPath;
       this.runfiles = loadRunfiles(manifestPath);
-    }
-
-    private static Map<String, String> loadRunfiles(String path) throws IOException {
-      HashMap<String, String> result = new HashMap<>();
-      try (BufferedReader r =
-          new BufferedReader(
-              new InputStreamReader(new FileInputStream(path), StandardCharsets.UTF_8))) {
-        String line = null;
-        while ((line = r.readLine()) != null) {
-          int index = line.indexOf(' ');
-          String runfile = (index == -1) ? line : line.substring(0, index);
-          String realPath = (index == -1) ? line : line.substring(index + 1);
-          result.put(runfile, realPath);
-        }
-      }
-      return Collections.unmodifiableMap(result);
-    }
-
-    private static String findRunfilesDir(String manifest) {
-      if (manifest.endsWith("/MANIFEST")
-          || manifest.endsWith("\\MANIFEST")
-          || manifest.endsWith(".runfiles_manifest")) {
-        String path = manifest.substring(0, manifest.length() - 9);
-        if (new File(path).isDirectory()) {
-          return path;
-        }
-      }
-      return "";
+      this.repoMapping = loadRepositoryMapping(rlocationChecked("_repo_mapping"));
     }
 
     @Override
-    public String rlocationChecked(String path) {
+    protected String rlocationChecked(String path) {
       String exactMatch = runfiles.get(path);
       if (exactMatch != null) {
         return exactMatch;
@@ -245,7 +465,7 @@ public String rlocationChecked(String path) {
     }
 
     @Override
-    public Map<String, String> getEnvVars() {
+    protected Map<String, String> getEnvVars() {
       HashMap<String, String> result = new HashMap<>(4);
       result.put("RUNFILES_MANIFEST_ONLY", "1");
       result.put("RUNFILES_MANIFEST_FILE", manifestPath);
@@ -255,25 +475,66 @@ public Map<String, String> getEnvVars() {
       result.put("JAVA_RUNFILES", runfilesDir);
       return result;
     }
+
+    @Override
+    protected Map<RepoMappingKey, String> getRepoMapping() {
+      return repoMapping;
+    }
+
+    private static Map<String, String> loadRunfiles(String path) throws IOException {
+      HashMap<String, String> result = new HashMap<>();
+      try (BufferedReader r =
+          new BufferedReader(
+              new InputStreamReader(new FileInputStream(path), StandardCharsets.UTF_8))) {
+        String line = null;
+        while ((line = r.readLine()) != null) {
+          int index = line.indexOf(' ');
+          String runfile = (index == -1) ? line : line.substring(0, index);
+          String realPath = (index == -1) ? line : line.substring(index + 1);
+          result.put(runfile, realPath);
+        }
+      }
+      return Collections.unmodifiableMap(result);
+    }
+
+    private static String findRunfilesDir(String manifest) {
+      if (manifest.endsWith("/MANIFEST")
+          || manifest.endsWith("\\MANIFEST")
+          || manifest.endsWith(".runfiles_manifest")) {
+        String path = manifest.substring(0, manifest.length() - 9);
+        if (new File(path).isDirectory()) {
+          return path;
+        }
+      }
+      return "";
+    }
   }
 
   /** {@link Runfiles} implementation that appends runfiles paths to the runfiles root. */
-  private static final class DirectoryBased extends Runfiles {
+  private static final class DirectoryBased extends Preloaded {
+
     private final String runfilesRoot;
+    private final Map<RepoMappingKey, String> repoMapping;
 
-    DirectoryBased(String runfilesDir) {
+    DirectoryBased(String runfilesDir) throws IOException {
       Util.checkArgument(!Util.isNullOrEmpty(runfilesDir));
       Util.checkArgument(new File(runfilesDir).isDirectory());
       this.runfilesRoot = runfilesDir;
+      this.repoMapping = loadRepositoryMapping(rlocationChecked("_repo_mapping"));
     }
 
     @Override
-    String rlocationChecked(String path) {
+    protected String rlocationChecked(String path) {
       return runfilesRoot + "/" + path;
     }
 
     @Override
-    public Map<String, String> getEnvVars() {
+    protected Map<RepoMappingKey, String> getRepoMapping() {
+      return repoMapping;
+    }
+
+    @Override
+    protected Map<String, String> getEnvVars() {
       HashMap<String, String> result = new HashMap<>(2);
       result.put("RUNFILES_DIR", runfilesRoot);
       // TODO(laszlocsomor): remove JAVA_RUNFILES once the Java launcher can pick up RUNFILES_DIR.
@@ -282,11 +543,11 @@ public Map<String, String> getEnvVars() {
     }
   }
 
-  static Runfiles createManifestBasedForTesting(String manifestPath) throws IOException {
+  static Preloaded createManifestBasedForTesting(String manifestPath) throws IOException {
     return new ManifestBased(manifestPath);
   }
 
-  static Runfiles createDirectoryBasedForTesting(String runfilesDir) {
+  static Preloaded createDirectoryBasedForTesting(String runfilesDir) throws IOException {
     return new DirectoryBased(runfilesDir);
   }
 }


Test patch:
diff --git a/src/test/py/bazel/bzlmod/bazel_module_test.py b/src/test/py/bazel/bzlmod/bazel_module_test.py
index 301477560bb224..b9fd78688c8d49 100644
--- a/src/test/py/bazel/bzlmod/bazel_module_test.py
+++ b/src/test/py/bazel/bzlmod/bazel_module_test.py
@@ -694,5 +694,73 @@ def testRunfilesRepoMappingManifest(self):
         self.Path('bazel-bin/external/bar~2.0/bar.runfiles_manifest')) as f:
       self.assertIn('_repo_mapping ', f.read())
 
+  def testJavaRunfilesLibraryRepoMapping(self):
+    self.main_registry.setModuleBasePath('projects')
+    projects_dir = self.main_registry.projects
+
+    self.main_registry.createLocalPathModule('data', '1.0', 'data')
+    projects_dir.joinpath('data').mkdir(exist_ok=True)
+    scratchFile(projects_dir.joinpath('data', 'WORKSPACE'))
+    scratchFile(projects_dir.joinpath('data', 'foo.txt'), ['hello'])
+    scratchFile(
+        projects_dir.joinpath('data', 'BUILD'), ['exports_files(["foo.txt"])'])
+
+    self.main_registry.createLocalPathModule('test', '1.0', 'test',
+                                             {'data': '1.0'})
+    projects_dir.joinpath('test').mkdir(exist_ok=True)
+    scratchFile(projects_dir.joinpath('test', 'WORKSPACE'))
+    scratchFile(
+        projects_dir.joinpath('test', 'BUILD'), [
+            'java_test(',
+            '    name = "test",',
+            '    srcs = ["Test.java"],',
+            '    main_class = "com.example.Test",',
+            '    use_testrunner = False,',
+            '    data = ["@data//:foo.txt"],',
+            '    args = ["$(rlocationpath @data//:foo.txt)"],',
+            '    deps = ["@bazel_tools//tools/java/runfiles"],',
+            ')',
+        ])
+    scratchFile(
+        projects_dir.joinpath('test', 'Test.java'), [
+            'package com.example;',
+            '',
+            'import com.google.devtools.build.runfiles.AutoBazelRepository;',
+            'import com.google.devtools.build.runfiles.Runfiles;',
+            '',
+            'import java.io.File;',
+            'import java.io.IOException;',
+            '',
+            '@AutoBazelRepository',
+            'public class Test {',
+            '  public static void main(String[] args) throws IOException {',
+            '    Runfiles.Preloaded rp = Runfiles.preload();',
+            '    if (!new File(rp.unmapped().rlocation(args[0])).exists()) {',
+            '      System.exit(1);',
+            '    }',
+            '    if (!new File(rp.withSourceRepository(AutoBazelRepository_Test.NAME).rlocation("data/foo.txt")).exists()) {',
+            '      System.exit(1);',
+            '    }',
+            '  }',
+            '}',
+        ])
+
+    self.ScratchFile('MODULE.bazel', ['bazel_dep(name="test",version="1.0")'])
+    self.ScratchFile('WORKSPACE')
+
+    # Run sandboxed on Linux and macOS.
+    exit_code, stderr, stdout = self.RunBazel([
+        'test', '@test//:test', '--test_output=errors',
+        '--test_env=RUNFILES_LIB_DEBUG=1'
+    ],
+                                              allow_failure=True)
+    self.AssertExitCode(exit_code, 0, stderr, stdout)
+    # Run unsandboxed on all platforms.
+    exit_code, stderr, stdout = self.RunBazel(
+        ['run', '@test//:test'],
+        allow_failure=True,
+        env_add={'RUNFILES_LIB_DEBUG': '1'})
+    self.AssertExitCode(exit_code, 0, stderr, stdout)
+
 if __name__ == '__main__':
   unittest.main()
diff --git a/tools/java/runfiles/testing/BUILD b/tools/java/runfiles/testing/BUILD
index 82d7836e16cde3..841724b36dc453 100644
--- a/tools/java/runfiles/testing/BUILD
+++ b/tools/java/runfiles/testing/BUILD
@@ -36,7 +36,6 @@ java_library(
     name = "test_deps",
     testonly = 1,
     exports = [
-        ":mock-file",
         "//third_party:guava",
         "//third_party:guava-testlib",
         "//third_party:junit4",
@@ -44,11 +43,3 @@ java_library(
         "//tools/java/runfiles",
     ],
 )
-
-java_library(
-    name = "mock-file",
-    testonly = 1,
-    srcs = ["MockFile.java"],
-    exports = ["//third_party:guava"],
-    deps = ["//third_party:guava"],
-)
diff --git a/tools/java/runfiles/testing/MockFile.java b/tools/java/runfiles/testing/MockFile.java
deleted file mode 100644
index 886bfcbcc82a38..00000000000000
--- a/tools/java/runfiles/testing/MockFile.java
+++ /dev/null
@@ -1,45 +0,0 @@
-// Copyright 2018 The Bazel Authors. All rights reserved.
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//    http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.devtools.build.runfiles;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
-import java.io.Closeable;
-import java.io.File;
-import java.io.IOException;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
-import java.nio.file.Path;
-
-final class MockFile implements Closeable {
-
-  public final Path path;
-
-  public MockFile(ImmutableList<String> lines) throws IOException {
-    String testTmpdir = System.getenv("TEST_TMPDIR");
-    if (Strings.isNullOrEmpty(testTmpdir)) {
-      throw new IOException("$TEST_TMPDIR is empty or undefined");
-    }
-    path = Files.createTempFile(new File(testTmpdir).toPath(), null, null);
-    Files.write(path, lines, StandardCharsets.UTF_8);
-  }
-
-  @Override
-  public void close() throws IOException {
-    if (path != null) {
-      Files.delete(path);
-    }
-  }
-}
diff --git a/tools/java/runfiles/testing/RunfilesTest.java b/tools/java/runfiles/testing/RunfilesTest.java
index e98ca554a8a27d..8b88ce28bd4e3d 100644
--- a/tools/java/runfiles/testing/RunfilesTest.java
+++ b/tools/java/runfiles/testing/RunfilesTest.java
@@ -25,10 +25,11 @@
 import java.nio.file.FileSystems;
 import java.nio.file.Files;
 import java.nio.file.Path;
-import java.util.Collections;
 import java.util.Map;
 import javax.annotation.Nullable;
+import org.junit.Rule;
 import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
 import org.junit.runner.RunWith;
 import org.junit.runners.JUnit4;
 
@@ -36,6 +37,9 @@
 @RunWith(JUnit4.class)
 public final class RunfilesTest {
 
+  @Rule
+  public TemporaryFolder tempDir = new TemporaryFolder(new File(System.getenv("TEST_TMPDIR")));
+
   private static boolean isWindows() {
     return File.separatorChar == '\\';
   }
@@ -44,7 +48,7 @@ private void assertRlocationArg(Runfiles runfiles, String path, @Nullable String
     IllegalArgumentException e =
         assertThrows(IllegalArgumentException.class, () -> runfiles.rlocation(path));
     if (error != null) {
-        assertThat(e).hasMessageThat().contains(error);
+      assertThat(e).hasMessageThat().contains(error);
     }
   }
 
@@ -71,24 +75,23 @@ public void testRlocationArgumentValidation() throws Exception {
 
   @Test
   public void testCreatesManifestBasedRunfiles() throws Exception {
-    try (MockFile mf = new MockFile(ImmutableList.of("a/b c/d"))) {
-      Runfiles r =
-          Runfiles.create(
-              ImmutableMap.of(
-                  "RUNFILES_MANIFEST_ONLY", "1",
-                  "RUNFILES_MANIFEST_FILE", mf.path.toString(),
-                  "RUNFILES_DIR", "ignored when RUNFILES_MANIFEST_ONLY=1",
-                  "JAVA_RUNFILES", "ignored when RUNFILES_DIR has a value",
-                  "TEST_SRCDIR", "should always be ignored"));
-      assertThat(r.rlocation("a/b")).isEqualTo("c/d");
-      assertThat(r.rlocation("foo")).isNull();
-
-      if (isWindows()) {
-        assertThat(r.rlocation("c:/foo")).isEqualTo("c:/foo");
-        assertThat(r.rlocation("c:\\foo")).isEqualTo("c:\\foo");
-      } else {
-        assertThat(r.rlocation("/foo")).isEqualTo("/foo");
-      }
+    Path mf = tempFile("foo.runfiles_manifest", ImmutableList.of("a/b c/d"));
+    Runfiles r =
+        Runfiles.create(
+            ImmutableMap.of(
+                "RUNFILES_MANIFEST_ONLY", "1",
+                "RUNFILES_MANIFEST_FILE", mf.toString(),
+                "RUNFILES_DIR", "ignored when RUNFILES_MANIFEST_ONLY=1",
+                "JAVA_RUNFILES", "ignored when RUNFILES_DIR has a value",
+                "TEST_SRCDIR", "should always be ignored"));
+    assertThat(r.rlocation("a/b")).isEqualTo("c/d");
+    assertThat(r.rlocation("foo")).isNull();
+
+    if (isWindows()) {
+      assertThat(r.rlocation("c:/foo")).isEqualTo("c:/foo");
+      assertThat(r.rlocation("c:\\foo")).isEqualTo("c:\\foo");
+    } else {
+      assertThat(r.rlocation("/foo")).isEqualTo("/foo");
     }
   }
 
@@ -143,11 +146,14 @@ public void testIgnoresTestSrcdirWhenJavaRunfilesIsUndefinedAndJustFails() throw
             () ->
                 Runfiles.create(
                     ImmutableMap.of(
-                        "RUNFILES_DIR", "",
-                        "JAVA_RUNFILES", "",
+                        "RUNFILES_DIR",
+                        "",
+                        "JAVA_RUNFILES",
+                        "",
                         "RUNFILES_MANIFEST_FILE",
-                            "ignored when RUNFILES_MANIFEST_ONLY is not set to 1",
-                        "TEST_SRCDIR", "should always be ignored")));
+                        "ignored when RUNFILES_MANIFEST_ONLY is not set to 1",
+                        "TEST_SRCDIR",
+                        "should always be ignored")));
     assertThat(e).hasMessageThat().contains("$RUNFILES_DIR and $JAVA_RUNFILES");
   }
 
@@ -166,12 +172,7 @@ public void testFailsToCreateManifestBasedBecauseManifestDoesNotExist() {
 
   @Test
   public void testManifestBasedEnvVars() throws Exception {
-    Path dir =
-        Files.createTempDirectory(
-            FileSystems.getDefault().getPath(System.getenv("TEST_TMPDIR")), null);
-
-    Path mf = dir.resolve("MANIFEST");
-    Files.write(mf, Collections.emptyList(), StandardCharsets.UTF_8);
+    Path mf = tempFile("MANIFEST", ImmutableList.of());
     Map<String, String> envvars =
         Runfiles.create(
                 ImmutableMap.of(
@@ -186,13 +187,12 @@ public void testManifestBasedEnvVars() throws Exception {
             "RUNFILES_MANIFEST_ONLY", "RUNFILES_MANIFEST_FILE", "RUNFILES_DIR", "JAVA_RUNFILES");
     assertThat(envvars.get("RUNFILES_MANIFEST_ONLY")).isEqualTo("1");
     assertThat(envvars.get("RUNFILES_MANIFEST_FILE")).isEqualTo(mf.toString());
-    assertThat(envvars.get("RUNFILES_DIR")).isEqualTo(dir.toString());
-    assertThat(envvars.get("JAVA_RUNFILES")).isEqualTo(dir.toString());
+    assertThat(envvars.get("RUNFILES_DIR")).isEqualTo(tempDir.getRoot().toString());
+    assertThat(envvars.get("JAVA_RUNFILES")).isEqualTo(tempDir.getRoot().toString());
 
-    Path rfDir = dir.resolve("foo.runfiles");
+    Path rfDir = tempDir.getRoot().toPath().resolve("foo.runfiles");
     Files.createDirectories(rfDir);
-    mf = dir.resolve("foo.runfiles_manifest");
-    Files.write(mf, Collections.emptyList(), StandardCharsets.UTF_8);
+    mf = tempFile("foo.runfiles_manifest", ImmutableList.of());
     envvars =
         Runfiles.create(
                 ImmutableMap.of(
@@ -210,81 +210,371 @@ public void testManifestBasedEnvVars() throws Exception {
 
   @Test
   public void testDirectoryBasedEnvVars() throws Exception {
-    Path dir =
-        Files.createTempDirectory(
-            FileSystems.getDefault().getPath(System.getenv("TEST_TMPDIR")), null);
-
     Map<String, String> envvars =
         Runfiles.create(
                 ImmutableMap.of(
                     "RUNFILES_MANIFEST_FILE",
                     "ignored when RUNFILES_MANIFEST_ONLY is not set to 1",
                     "RUNFILES_DIR",
-                    dir.toString(),
+                    tempDir.getRoot().toString(),
                     "JAVA_RUNFILES",
                     "ignored when RUNFILES_DIR has a value",
                     "TEST_SRCDIR",
                     "should always be ignored"))
             .getEnvVars();
     assertThat(envvars.keySet()).containsExactly("RUNFILES_DIR", "JAVA_RUNFILES");
-    assertThat(envvars.get("RUNFILES_DIR")).isEqualTo(dir.toString());
-    assertThat(envvars.get("JAVA_RUNFILES")).isEqualTo(dir.toString());
+    assertThat(envvars.get("RUNFILES_DIR")).isEqualTo(tempDir.getRoot().toString());
+    assertThat(envvars.get("JAVA_RUNFILES")).isEqualTo(tempDir.getRoot().toString());
   }
 
   @Test
-  public void testDirectoryBasedRlocation() {
+  public void testDirectoryBasedRlocation() throws IOException {
     // The DirectoryBased implementation simply joins the runfiles directory and the runfile's path
     // on a "/". DirectoryBased does not perform any normalization, nor does it check that the path
     // exists.
     File dir = new File(System.getenv("TEST_TMPDIR"), "mock/runfiles");
     assertThat(dir.mkdirs()).isTrue();
-    Runfiles r = Runfiles.createDirectoryBasedForTesting(dir.toString());
+    Runfiles r = Runfiles.createDirectoryBasedForTesting(dir.toString()).withSourceRepository("");
     // Escaping for "\": once for string and once for regex.
     assertThat(r.rlocation("arg")).matches(".*[/\\\\]mock[/\\\\]runfiles[/\\\\]arg");
   }
 
   @Test
   public void testManifestBasedRlocation() throws Exception {
-    try (MockFile mf =
-        new MockFile(
+    Path mf =
+        tempFile(
+            "MANIFEST",
             ImmutableList.of(
                 "Foo/runfile1 C:/Actual Path\\runfile1",
                 "Foo/Bar/runfile2 D:\\the path\\run file 2.txt",
-                "Foo/Bar/Dir E:\\Actual Path\\Directory"))) {
-      Runfiles r = Runfiles.createManifestBasedForTesting(mf.path.toString());
-      assertThat(r.rlocation("Foo/runfile1")).isEqualTo("C:/Actual Path\\runfile1");
-      assertThat(r.rlocation("Foo/Bar/runfile2")).isEqualTo("D:\\the path\\run file 2.txt");
-      assertThat(r.rlocation("Foo/Bar/Dir")).isEqualTo("E:\\Actual Path\\Directory");
-      assertThat(r.rlocation("Foo/Bar/Dir/File")).isEqualTo("E:\\Actual Path\\Directory/File");
-      assertThat(r.rlocation("Foo/Bar/Dir/Deeply/Nested/File"))
-          .isEqualTo("E:\\Actual Path\\Directory/Deeply/Nested/File");
-      assertThat(r.rlocation("unknown")).isNull();
-    }
+                "Foo/Bar/Dir E:\\Actual Path\\Directory"));
+    Runfiles r = Runfiles.createManifestBasedForTesting(mf.toString()).withSourceRepository("");
+    assertThat(r.rlocation("Foo/runfile1")).isEqualTo("C:/Actual Path\\runfile1");
+    assertThat(r.rlocation("Foo/Bar/runfile2")).isEqualTo("D:\\the path\\run file 2.txt");
+    assertThat(r.rlocation("Foo/Bar/Dir")).isEqualTo("E:\\Actual Path\\Directory");
+    assertThat(r.rlocation("Foo/Bar/Dir/File")).isEqualTo("E:\\Actual Path\\Directory/File");
+    assertThat(r.rlocation("Foo/Bar/Dir/Deeply/Nested/File"))
+        .isEqualTo("E:\\Actual Path\\Directory/Deeply/Nested/File");
+    assertThat(r.rlocation("unknown")).isNull();
+  }
+
+  @Test
+  public void testManifestBasedRlocationWithRepoMapping_fromMain() throws Exception {
+    Path rm =
+        tempFile(
+            "foo.repo_mapping",
+            ImmutableList.of(
+                ",my_module,_main",
+                ",my_protobuf,protobuf~3.19.2",
+                ",my_workspace,_main",
+                "protobuf~3.19.2,protobuf,protobuf~3.19.2"));
+    Path mf =
+        tempFile(
+            "foo.runfiles_manifest",
+            ImmutableList.of(
+                "_repo_mapping " + rm,
+                "config.json /etc/config.json",
+                "protobuf~3.19.2/foo/runfile C:/Actual Path\\protobuf\\runfile",
+                "_main/bar/runfile /the/path/./to/other//other runfile.txt",
+                "protobuf~3.19.2/bar/dir E:\\Actual Path\\Directory"));
+    Runfiles r = Runfiles.createManifestBasedForTesting(mf.toString()).withSourceRepository("");
+
+    assertThat(r.rlocation("my_module/bar/runfile"))
+        .isEqualTo("/the/path/./to/other//other runfile.txt");
+    assertThat(r.rlocation("my_workspace/bar/runfile"))
+        .isEqualTo("/the/path/./to/other//other runfile.txt");
+    assertThat(r.rlocation("my_protobuf/foo/runfile"))
+        .isEqualTo("C:/Actual Path\\protobuf\\runfile");
+    assertThat(r.rlocation("my_protobuf/bar/dir")).isEqualTo("E:\\Actual Path\\Directory");
+    assertThat(r.rlocation("my_protobuf/bar/dir/file"))
+        .isEqualTo("E:\\Actual Path\\Directory/file");
+    assertThat(r.rlocation("my_protobuf/bar/dir/de eply/nes ted/fi~le"))
+        .isEqualTo("E:\\Actual Path\\Directory/de eply/nes ted/fi~le");
+
+    assertThat(r.rlocation("protobuf/foo/runfile")).isNull();
+    assertThat(r.rlocation("protobuf/bar/dir")).isNull();
+    assertThat(r.rlocation("protobuf/bar/dir/file")).isNull();
+    assertThat(r.rlocation("protobuf/bar/dir/dir/de eply/nes ted/fi~le")).isNull();
+
+    assertThat(r.rlocation("_main/bar/runfile"))
+        .isEqualTo("/the/path/./to/other//other runfile.txt");
+    assertThat(r.rlocation("protobuf~3.19.2/foo/runfile"))
+        .isEqualTo("C:/Actual Path\\protobuf\\runfile");
+    assertThat(r.rlocation("protobuf~3.19.2/bar/dir")).isEqualTo("E:\\Actual Path\\Directory");
+    assertThat(r.rlocation("protobuf~3.19.2/bar/dir/file"))
+        .isEqualTo("E:\\Actual Path\\Directory/file");
+    assertThat(r.rlocation("protobuf~3.19.2/bar/dir/de eply/nes  ted/fi~le"))
+        .isEqualTo("E:\\Actual Path\\Directory/de eply/nes  ted/fi~le");
+
+    assertThat(r.rlocation("config.json")).isEqualTo("/etc/config.json");
+    assertThat(r.rlocation("_main")).isNull();
+    assertThat(r.rlocation("my_module")).isNull();
+    assertThat(r.rlocation("protobuf")).isNull();
+  }
+
+  @Test
+  public void testManifestBasedRlocationUnmapped() throws Exception {
+    Path rm =
+        tempFile(
+            "foo.repo_mapping",
+            ImmutableList.of(
+                ",my_module,_main",
+                ",my_protobuf,protobuf~3.19.2",
+                ",my_workspace,_main",
+                "protobuf~3.19.2,protobuf,protobuf~3.19.2"));
+    Path mf =
+        tempFile(
+            "foo.runfiles_manifest",
+            ImmutableList.of(
+                "_repo_mapping " + rm,
+                "config.json /etc/config.json",
+                "protobuf~3.19.2/foo/runfile C:/Actual Path\\protobuf\\runfile",
+                "_main/bar/runfile /the/path/./to/other//other runfile.txt",
+                "protobuf~3.19.2/bar/dir E:\\Actual Path\\Directory"));
+    Runfiles r = Runfiles.createManifestBasedForTesting(mf.toString()).unmapped();
+
+    assertThat(r.rlocation("my_module/bar/runfile")).isNull();
+    assertThat(r.rlocation("my_workspace/bar/runfile")).isNull();
+    assertThat(r.rlocation("my_protobuf/foo/runfile")).isNull();
+    assertThat(r.rlocation("my_protobuf/bar/dir")).isNull();
+    assertThat(r.rlocation("my_protobuf/bar/dir/file")).isNull();
+    assertThat(r.rlocation("my_protobuf/bar/dir/de eply/nes ted/fi~le")).isNull();
+
+    assertThat(r.rlocation("protobuf/foo/runfile")).isNull();
+    assertThat(r.rlocation("protobuf/bar/dir")).isNull();
+    assertThat(r.rlocation("protobuf/bar/dir/file")).isNull();
+    assertThat(r.rlocation("protobuf/bar/dir/dir/de eply/nes ted/fi~le")).isNull();
+
+    assertThat(r.rlocation("_main/bar/runfile"))
+        .isEqualTo("/the/path/./to/other//other runfile.txt");
+    assertThat(r.rlocation("protobuf~3.19.2/foo/runfile"))
+        .isEqualTo("C:/Actual Path\\protobuf\\runfile");
+    assertThat(r.rlocation("protobuf~3.19.2/bar/dir")).isEqualTo("E:\\Actual Path\\Directory");
+    assertThat(r.rlocation("protobuf~3.19.2/bar/dir/file"))
+        .isEqualTo("E:\\Actual Path\\Directory/file");
+    assertThat(r.rlocation("protobuf~3.19.2/bar/dir/de eply/nes  ted/fi~le"))
+        .isEqualTo("E:\\Actual Path\\Directory/de eply/nes  ted/fi~le");
+
+    assertThat(r.rlocation("config.json")).isEqualTo("/etc/config.json");
+    assertThat(r.rlocation("_main")).isNull();
+    assertThat(r.rlocation("my_module")).isNull();
+    assertThat(r.rlocation("protobuf")).isNull();
+  }
+
+  @Test
+  public void testManifestBasedRlocationWithRepoMapping_fromOtherRepo() throws Exception {
+    Path rm =
+        tempFile(
+            "foo.repo_mapping",
+            ImmutableList.of(
+                ",my_module,_main",
+                ",my_protobuf,protobuf~3.19.2",
+                ",my_workspace,_main",
+                "protobuf~3.19.2,protobuf,protobuf~3.19.2"));
+    Path mf =
+        tempFile(
+            "foo.runfiles/MANIFEST",
+            ImmutableList.of(
+                "_repo_mapping " + rm,
+                "config.json /etc/config.json",
+                "protobuf~3.19.2/foo/runfile C:/Actual Path\\protobuf\\runfile",
+                "_main/bar/runfile /the/path/./to/other//other runfile.txt",
+                "protobuf~3.19.2/bar/dir E:\\Actual Path\\Directory"));
+    Runfiles r =
+        Runfiles.createManifestBasedForTesting(mf.toString())
+            .withSourceRepository("protobuf~3.19.2");
+
+    assertThat(r.rlocation("protobuf/foo/runfile")).isEqualTo("C:/Actual Path\\protobuf\\runfile");
+    assertThat(r.rlocation("protobuf/bar/dir")).isEqualTo("E:\\Actual Path\\Directory");
+    assertThat(r.rlocation("protobuf/bar/dir/file")).isEqualTo("E:\\Actual Path\\Directory/file");
+    assertThat(r.rlocation("protobuf/bar/dir/de eply/nes  ted/fi~le"))
+        .isEqualTo("E:\\Actual Path\\Directory/de eply/nes  ted/fi~le");
+
+    assertThat(r.rlocation("my_module/bar/runfile")).isNull();
+    assertThat(r.rlocation("my_protobuf/foo/runfile")).isNull();
+    assertThat(r.rlocation("my_protobuf/bar/dir")).isNull();
+    assertThat(r.rlocation("my_protobuf/bar/dir/file")).isNull();
+    assertThat(r.rlocation("my_protobuf/bar/dir/de eply/nes  ted/fi~le")).isNull();
+
+    assertThat(r.rlocation("_main/bar/runfile"))
+        .isEqualTo("/the/path/./to/other//other runfile.txt");
+    assertThat(r.rlocation("protobuf~3.19.2/foo/runfile"))
+        .isEqualTo("C:/Actual Path\\protobuf\\runfile");
+    assertThat(r.rlocation("protobuf~3.19.2/bar/dir")).isEqualTo("E:\\Actual Path\\Directory");
+    assertThat(r.rlocation("protobuf~3.19.2/bar/dir/file"))
+        .isEqualTo("E:\\Actual Path\\Directory/file");
+    assertThat(r.rlocation("protobuf~3.19.2/bar/dir/de eply/nes  ted/fi~le"))
+        .isEqualTo("E:\\Actual Path\\Directory/de eply/nes  ted/fi~le");
+
+    assertThat(r.rlocation("config.json")).isEqualTo("/etc/config.json");
+    assertThat(r.rlocation("_main")).isNull();
+    assertThat(r.rlocation("my_module")).isNull();
+    assertThat(r.rlocation("protobuf")).isNull();
+  }
+
+  @Test
+  public void testDirectoryBasedRlocationWithRepoMapping_fromMain() throws Exception {
+    Path dir = tempDir.newFolder("foo.runfiles").toPath();
+    Path unused =
+        tempFile(
+            dir.resolve("_repo_mapping").toString(),
+            ImmutableList.of(
+                ",my_module,_main",
+                ",my_protobuf,protobuf~3.19.2",
+                ",my_workspace,_main",
+                "protobuf~3.19.2,protobuf,protobuf~3.19.2"));
+    Runfiles r = Runfiles.createDirectoryBasedForTesting(dir.toString()).withSourceRepository("");
+
+    assertThat(r.rlocation("my_module/bar/runfile")).isEqualTo(dir + "/_main/bar/runfile");
+    assertThat(r.rlocation("my_workspace/bar/runfile")).isEqualTo(dir + "/_main/bar/runfile");
+    assertThat(r.rlocation("my_protobuf/foo/runfile"))
+        .isEqualTo(dir + "/protobuf~3.19.2/foo/runfile");
+    assertThat(r.rlocation("my_protobuf/bar/dir")).isEqualTo(dir + "/protobuf~3.19.2/bar/dir");
+    assertThat(r.rlocation("my_protobuf/bar/dir/file"))
+        .isEqualTo(dir + "/protobuf~3.19.2/bar/dir/file");
+    assertThat(r.rlocation("my_protobuf/bar/dir/de eply/nes ted/fi~le"))
+        .isEqualTo(dir + "/protobuf~3.19.2/bar/dir/de eply/nes ted/fi~le");
+
+    assertThat(r.rlocation("protobuf/foo/runfile")).isEqualTo(dir + "/protobuf/foo/runfile");
+    assertThat(r.rlocation("protobuf/bar/dir/dir/de eply/nes ted/fi~le"))
+        .isEqualTo(dir + "/protobuf/bar/dir/dir/de eply/nes ted/fi~le");
+
+    assertThat(r.rlocation("_main/bar/runfile")).isEqualTo(dir + "/_main/bar/runfile");
+    assertThat(r.rlocation("protobuf~3.19.2/foo/runfile"))
+        .isEqualTo(dir + "/protobuf~3.19.2/foo/runfile");
+    assertThat(r.rlocation("protobuf~3.19.2/bar/dir")).isEqualTo(dir + "/protobuf~3.19.2/bar/dir");
+    assertThat(r.rlocation("protobuf~3.19.2/bar/dir/file"))
+        .isEqualTo(dir + "/protobuf~3.19.2/bar/dir/file");
+    assertThat(r.rlocation("protobuf~3.19.2/bar/dir/de eply/nes  ted/fi~le"))
+        .isEqualTo(dir + "/protobuf~3.19.2/bar/dir/de eply/nes  ted/fi~le");
+
+    assertThat(r.rlocation("config.json")).isEqualTo(dir + "/config.json");
+  }
+
+  @Test
+  public void testDirectoryBasedRlocationUnmapped() throws Exception {
+    Path dir = tempDir.newFolder("foo.runfiles").toPath();
+    Path unused =
+        tempFile(
+            dir.resolve("_repo_mapping").toString(),
+            ImmutableList.of(
+                ",my_module,_main",
+                ",my_protobuf,protobuf~3.19.2",
+                ",my_workspace,_main",
+                "protobuf~3.19.2,protobuf,protobuf~3.19.2"));
+    Runfiles r = Runfiles.createDirectoryBasedForTesting(dir.toString()).unmapped();
+
+    assertThat(r.rlocation("my_module/bar/runfile")).isEqualTo(dir + "/my_module/bar/runfile");
+    assertThat(r.rlocation("my_workspace/bar/runfile"))
+        .isEqualTo(dir + "/my_workspace/bar/runfile");
+    assertThat(r.rlocation("my_protobuf/foo/runfile")).isEqualTo(dir + "/my_protobuf/foo/runfile");
+    assertThat(r.rlocation("my_protobuf/bar/dir")).isEqualTo(dir + "/my_protobuf/bar/dir");
+    assertThat(r.rlocation("my_protobuf/bar/dir/file"))
+        .isEqualTo(dir + "/my_protobuf/bar/dir/file");
+    assertThat(r.rlocation("my_protobuf/bar/dir/de eply/nes ted/fi~le"))
+        .isEqualTo(dir + "/my_protobuf/bar/dir/de eply/nes ted/fi~le");
+
+    assertThat(r.rlocation("protobuf/foo/runfile")).isEqualTo(dir + "/protobuf/foo/runfile");
+    assertThat(r.rlocation("protobuf/bar/dir/dir/de eply/nes ted/fi~le"))
+        .isEqualTo(dir + "/protobuf/bar/dir/dir/de eply/nes ted/fi~le");
+
+    assertThat(r.rlocation("_main/bar/runfile")).isEqualTo(dir + "/_main/bar/runfile");
+    assertThat(r.rlocation("protobuf~3.19.2/foo/runfile"))
+        .isEqualTo(dir + "/protobuf~3.19.2/foo/runfile");
+    assertThat(r.rlocation("protobuf~3.19.2/bar/dir")).isEqualTo(dir + "/protobuf~3.19.2/bar/dir");
+    assertThat(r.rlocation("protobuf~3.19.2/bar/dir/file"))
+        .isEqualTo(dir + "/protobuf~3.19.2/bar/dir/file");
+    assertThat(r.rlocation("protobuf~3.19.2/bar/dir/de eply/nes  ted/fi~le"))
+        .isEqualTo(dir + "/protobuf~3.19.2/bar/dir/de eply/nes  ted/fi~le");
+
+    assertThat(r.rlocation("config.json")).isEqualTo(dir + "/config.json");
+  }
+
+  @Test
+  public void testDirectoryBasedRlocationWithRepoMapping_fromOtherRepo() throws Exception {
+    Path dir = tempDir.newFolder("foo.runfiles").toPath();
+    Path unused =
+        tempFile(
+            dir.resolve("_repo_mapping").toString(),
+            ImmutableList.of(
+                ",my_module,_main",
+                ",my_protobuf,protobuf~3.19.2",
+                ",my_workspace,_main",
+                "protobuf~3.19.2,protobuf,protobuf~3.19.2"));
+    Runfiles r =
+        Runfiles.createDirectoryBasedForTesting(dir.toString())
+            .withSourceRepository("protobuf~3.19.2");
+
+    assertThat(r.rlocation("protobuf/foo/runfile")).isEqualTo(dir + "/protobuf~3.19.2/foo/runfile");
+    assertThat(r.rlocation("protobuf/bar/dir")).isEqualTo(dir + "/protobuf~3.19.2/bar/dir");
+    assertThat(r.rlocation("protobuf/bar/dir/file"))
+        .isEqualTo(dir + "/protobuf~3.19.2/bar/dir/file");
+    assertThat(r.rlocation("protobuf/bar/dir/de eply/nes  ted/fi~le"))
+        .isEqualTo(dir + "/protobuf~3.19.2/bar/dir/de eply/nes  ted/fi~le");
+
+    assertThat(r.rlocation("my_module/bar/runfile")).isEqualTo(dir + "/my_module/bar/runfile");
+    assertThat(r.rlocation("my_protobuf/bar/dir/de eply/nes  ted/fi~le"))
+        .isEqualTo(dir + "/my_protobuf/bar/dir/de eply/nes  ted/fi~le");
+
+    assertThat(r.rlocation("_main/bar/runfile")).isEqualTo(dir + "/_main/bar/runfile");
+    assertThat(r.rlocation("protobuf~3.19.2/foo/runfile"))
+        .isEqualTo(dir + "/protobuf~3.19.2/foo/runfile");
+    assertThat(r.rlocation("protobuf~3.19.2/bar/dir")).isEqualTo(dir + "/protobuf~3.19.2/bar/dir");
+    assertThat(r.rlocation("protobuf~3.19.2/bar/dir/file"))
+        .isEqualTo(dir + "/protobuf~3.19.2/bar/dir/file");
+    assertThat(r.rlocation("protobuf~3.19.2/bar/dir/de eply/nes  ted/fi~le"))
+        .isEqualTo(dir + "/protobuf~3.19.2/bar/dir/de eply/nes  ted/fi~le");
+
+    assertThat(r.rlocation("config.json")).isEqualTo(dir + "/config.json");
   }
 
   @Test
-  public void testDirectoryBasedCtorArgumentValidation() {
+  public void testDirectoryBasedCtorArgumentValidation() throws IOException {
     assertThrows(
-        IllegalArgumentException.class, () -> Runfiles.createDirectoryBasedForTesting(null));
+        IllegalArgumentException.class,
+        () -> Runfiles.createDirectoryBasedForTesting(null).withSourceRepository(""));
 
-    assertThrows(IllegalArgumentException.class, () -> Runfiles.createDirectoryBasedForTesting(""));
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> Runfiles.createDirectoryBasedForTesting("").withSourceRepository(""));
 
     assertThrows(
         IllegalArgumentException.class,
-        () -> Runfiles.createDirectoryBasedForTesting("non-existent directory is bad"));
+        () ->
+            Runfiles.createDirectoryBasedForTesting("non-existent directory is bad")
+                .withSourceRepository(""));
 
-    Runfiles.createDirectoryBasedForTesting(System.getenv("TEST_TMPDIR"));
+    var unused =
+        Runfiles.createDirectoryBasedForTesting(System.getenv("TEST_TMPDIR"))
+            .withSourceRepository("");
   }
 
   @Test
   public void testManifestBasedCtorArgumentValidation() throws Exception {
     assertThrows(
-        IllegalArgumentException.class, () -> Runfiles.createManifestBasedForTesting(null));
+        IllegalArgumentException.class,
+        () -> Runfiles.createManifestBasedForTesting(null).withSourceRepository(""));
 
-    assertThrows(IllegalArgumentException.class, () -> Runfiles.createManifestBasedForTesting(""));
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> Runfiles.createManifestBasedForTesting("").withSourceRepository(""));
 
-    try (MockFile mf = new MockFile(ImmutableList.of("a b"))) {
-      Runfiles.createManifestBasedForTesting(mf.path.toString());
-    }
+    Path mf = tempFile("foobar", ImmutableList.of("a b"));
+    var unused = Runfiles.createManifestBasedForTesting(mf.toString()).withSourceRepository("");
+  }
+
+  @Test
+  public void testInvalidRepoMapping() throws Exception {
+    Path rm = tempFile("foo.repo_mapping", ImmutableList.of("a,b,c,d"));
+    Path mf = tempFile("foo.runfiles/MANIFEST", ImmutableList.of("_repo_mapping " + rm));
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> Runfiles.createManifestBasedForTesting(mf.toString()).withSourceRepository(""));
+  }
+
+  private Path tempFile(String path, ImmutableList<String> lines) throws IOException {
+    Path file = tempDir.getRoot().toPath().resolve(path.replace('/', File.separatorChar));
+    Files.createDirectories(file.getParent());
+    return Files.write(file, lines, StandardCharsets.UTF_8);
   }
 }