Copy over runfiles library from Bazel

Copybara Import from https://github.com/bazelbuild/rules_java/pull/239

BEGIN_PUBLIC
Copy over runfiles library from Bazel (#239)

The runfiles library can be maintained independently of Bazel releases and `bazel_tools` can refer to it via an alias.

Also set flags to build and test with a hermetic JDK 8 to ensure compatibility with that version.

Closes #239
END_PUBLIC

COPYBARA_INTEGRATE_REVIEW=https://github.com/bazelbuild/rules_java/pull/239 from fmeum:runfiles 6428328ba2bb206a281e7c02e80827f638216d96
PiperOrigin-RevId: 696993940
Change-Id: Id2ee9f4f2d15a1063c4db1e913b375ba40b8e439
diff --git a/.gitignore b/.gitignore
index ef43625..86e15ab 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,3 +17,5 @@
 # Ignore jekyll build output.
 /production
 /.sass-cache
+# Ignore MODULE.bazel.lock as this is a library project.
+MODULE.bazel.lock
diff --git a/MODULE.bazel b/MODULE.bazel
index 77f7bb5..ec26242 100644
--- a/MODULE.bazel
+++ b/MODULE.bazel
@@ -89,11 +89,14 @@
 
 [register_toolchains("@" + name + "_toolchain_config_repo//:all") for name in REMOTE_JDK_REPOS]
 
+# Compatibility layer
+compat = use_extension("//java:extensions.bzl", "compatibility_proxy")
+use_repo(compat, "compatibility_proxy")
+
 # Dev dependencies
 bazel_dep(name = "rules_pkg", version = "0.9.1", dev_dependency = True)
 bazel_dep(name = "stardoc", version = "0.7.1", dev_dependency = True)
 bazel_dep(name = "rules_shell", version = "0.2.0", dev_dependency = True)
 
-# Compatibility layer
-compat = use_extension("//java:extensions.bzl", "compatibility_proxy")
-use_repo(compat, "compatibility_proxy")
+test_repositories = use_extension("//test:repositories.bzl", "test_repositories_ext", dev_dependency = True)
+use_repo(test_repositories, "guava", "truth")
diff --git a/WORKSPACE b/WORKSPACE
index 49422df..f58f31c 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -50,3 +50,7 @@
 load("@stardoc//:setup.bzl", "stardoc_repositories")
 
 stardoc_repositories()
+
+load("//test:repositories.bzl", "test_repositories")
+
+test_repositories()
diff --git a/java/runfiles/BUILD.bazel b/java/runfiles/BUILD.bazel
new file mode 100644
index 0000000..792e45f
--- /dev/null
+++ b/java/runfiles/BUILD.bazel
@@ -0,0 +1,5 @@
+alias(
+    name = "runfiles",
+    actual = "//java/runfiles/src/main/java/com/google/devtools/build/runfiles",
+    visibility = ["//visibility:public"],
+)
diff --git a/java/runfiles/src/main/java/com/google/devtools/build/runfiles/AutoBazelRepository.java b/java/runfiles/src/main/java/com/google/devtools/build/runfiles/AutoBazelRepository.java
new file mode 100644
index 0000000..6dc5330
--- /dev/null
+++ b/java/runfiles/src/main/java/com/google/devtools/build/runfiles/AutoBazelRepository.java
@@ -0,0 +1,29 @@
+// Copyright 2022 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 java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Annotating a class {@code Fooer} with this annotation generates a class {@code
+ * AutoBazelRepository_Fooer} defining a {@link String} constant {@code NAME} containing the
+ * canonical name of the repository containing the Bazel target that compiled the annotated class.
+ */
+@Retention(RetentionPolicy.SOURCE)
+@Target(ElementType.TYPE)
+public @interface AutoBazelRepository {}
diff --git a/java/runfiles/src/main/java/com/google/devtools/build/runfiles/AutoBazelRepositoryProcessor.java b/java/runfiles/src/main/java/com/google/devtools/build/runfiles/AutoBazelRepositoryProcessor.java
new file mode 100644
index 0000000..2b0ce9d
--- /dev/null
+++ b/java/runfiles/src/main/java/com/google/devtools/build/runfiles/AutoBazelRepositoryProcessor.java
@@ -0,0 +1,121 @@
+// Copyright 2022 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 java.io.IOException;
+import java.io.PrintWriter;
+import java.util.ArrayDeque;
+import java.util.Deque;
+import java.util.Set;
+import javax.annotation.processing.AbstractProcessor;
+import javax.annotation.processing.RoundEnvironment;
+import javax.annotation.processing.SupportedAnnotationTypes;
+import javax.annotation.processing.SupportedOptions;
+import javax.lang.model.SourceVersion;
+import javax.lang.model.element.Element;
+import javax.lang.model.element.TypeElement;
+import javax.tools.Diagnostic.Kind;
+
+/** Processor for {@link AutoBazelRepository}. */
+@SupportedAnnotationTypes("com.google.devtools.build.runfiles.AutoBazelRepository")
+@SupportedOptions(AutoBazelRepositoryProcessor.BAZEL_REPOSITORY_OPTION)
+public final class AutoBazelRepositoryProcessor extends AbstractProcessor {
+
+  static final String BAZEL_REPOSITORY_OPTION = "bazel.repository";
+
+  @Override
+  public SourceVersion getSupportedSourceVersion() {
+    return SourceVersion.latestSupported();
+  }
+
+  @Override
+  public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
+    annotations.stream()
+        .flatMap(element -> roundEnv.getElementsAnnotatedWith(element).stream())
+        .map(element -> (TypeElement) element)
+        .forEach(this::emitClass);
+    return false;
+  }
+
+  private void emitClass(TypeElement annotatedClass) {
+    // This option is always provided by the Java rule implementations.
+    if (!processingEnv.getOptions().containsKey(BAZEL_REPOSITORY_OPTION)) {
+      processingEnv
+          .getMessager()
+          .printMessage(
+              Kind.ERROR,
+              String.format(
+                  "The %1$s annotation processor option is not set. To use this annotation"
+                      + " processor, provide the canonical repository name of the current target as"
+                      + " the value of the -A%1$s flag.",
+                  BAZEL_REPOSITORY_OPTION),
+              annotatedClass);
+      return;
+    }
+    String repositoryName = processingEnv.getOptions().get(BAZEL_REPOSITORY_OPTION);
+    if (repositoryName == null) {
+      // javac translates '-Abazel.repository=' into a null value.
+      // https://github.com/openjdk/jdk/blob/7a49c9baa1d4ad7df90e7ca626ec48ba76881822/src/jdk.compiler/share/classes/com/sun/tools/javac/processing/JavacProcessingEnvironment.java#L651
+      repositoryName = "";
+    }
+
+    // For a nested class Outer.Middle.Inner, generate a class with simple name
+    // AutoBazelRepository_Outer_Middle_Inner.
+    // Note: There can be collisions when local classes are involved, but since the definition of a
+    // class depends only on the containing Bazel target, this does not result in ambiguity.
+    Deque<String> classNameSegments = new ArrayDeque<>();
+    Element element = annotatedClass;
+    while (element instanceof TypeElement) {
+      classNameSegments.addFirst(element.getSimpleName().toString());
+      element = element.getEnclosingElement();
+    }
+    classNameSegments.addFirst("AutoBazelRepository");
+    String generatedClassSimpleName = String.join("_", classNameSegments);
+
+    String generatedClassPackage =
+        processingEnv.getElementUtils().getPackageOf(annotatedClass).getQualifiedName().toString();
+
+    String generatedClassName =
+        generatedClassPackage.isEmpty()
+            ? generatedClassSimpleName
+            : generatedClassPackage + "." + generatedClassSimpleName;
+
+    try (PrintWriter out =
+        new PrintWriter(
+            processingEnv.getFiler().createSourceFile(generatedClassName).openWriter())) {
+      if (!generatedClassPackage.isEmpty()) {
+        // This annotation may exist on a class which is at the root package
+        out.printf("package %s;\n", generatedClassPackage);
+      }
+      out.printf("\n");
+      out.printf("class %s {\n", generatedClassSimpleName);
+      out.printf("  /**\n");
+      out.printf("   * The canonical name of the repository containing the Bazel target that\n");
+      out.printf("   * compiled {@link %s}.\n", annotatedClass.getQualifiedName().toString());
+      out.printf("   */\n");
+      out.printf("  static final String NAME = \"%s\";\n", repositoryName);
+      out.printf("\n");
+      out.printf("  private %s() {}\n", generatedClassSimpleName);
+      out.printf("}\n");
+    } catch (IOException e) {
+      processingEnv
+          .getMessager()
+          .printMessage(
+              Kind.ERROR,
+              String.format("Failed to generate %s: %s", generatedClassName, e.getMessage()),
+              annotatedClass);
+    }
+  }
+}
diff --git a/java/runfiles/src/main/java/com/google/devtools/build/runfiles/BUILD.bazel b/java/runfiles/src/main/java/com/google/devtools/build/runfiles/BUILD.bazel
new file mode 100644
index 0000000..fd351b7
--- /dev/null
+++ b/java/runfiles/src/main/java/com/google/devtools/build/runfiles/BUILD.bazel
@@ -0,0 +1,23 @@
+load("@rules_java//java:defs.bzl", "java_library", "java_plugin")
+
+java_library(
+    name = "runfiles",
+    srcs = [
+        "Runfiles.java",
+        "Util.java",
+    ],
+    exported_plugins = [":auto_bazel_repository_processor"],
+    visibility = ["//java/runfiles:__pkg__"],
+    exports = [":auto_bazel_repository"],
+)
+
+java_library(
+    name = "auto_bazel_repository",
+    srcs = ["AutoBazelRepository.java"],
+)
+
+java_plugin(
+    name = "auto_bazel_repository_processor",
+    srcs = ["AutoBazelRepositoryProcessor.java"],
+    processor_class = "com.google.devtools.build.runfiles.AutoBazelRepositoryProcessor",
+)
diff --git a/java/runfiles/src/main/java/com/google/devtools/build/runfiles/Runfiles.java b/java/runfiles/src/main/java/com/google/devtools/build/runfiles/Runfiles.java
new file mode 100644
index 0000000..bec2091
--- /dev/null
+++ b/java/runfiles/src/main/java/com/google/devtools/build/runfiles/Runfiles.java
@@ -0,0 +1,582 @@
+// 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 java.io.BufferedReader;
+import java.io.File;
+import java.io.FileInputStream;
+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.
+ *
+ * <p>USAGE:
+ *
+ * <p>1. Depend on this runfiles library from your build rule:
+ *
+ * <pre>
+ *   java_binary(
+ *       name = "my_binary",
+ *       ...
+ *       deps = ["@rules_java//java/runfiles"],
+ *   )
+ * </pre>
+ *
+ * <p>2. Import the runfiles library.
+ *
+ * <pre>
+ *   import com.google.devtools.build.runfiles.Runfiles;
+ * </pre>
+ *
+ * <p>3. Create a {@link Runfiles.Preloaded} object:
+ *
+ * <pre>
+ *   public void myFunction() {
+ *     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:
+ *
+ * <pre>
+ *   String path = r.rlocation("path/to/binary");
+ *   ProcessBuilder pb = new ProcessBuilder(path);
+ *   pb.environment().putAll(r.getEnvVars());
+ *   ...
+ *   Process p = pb.start();
+ * </pre>
+ *
+ * <h3>{@link Runfiles.Preloaded} vs. {@link Runfiles}</h3>
+ *
+ * <p>Instances of {@link Runfiles.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(java.util.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.Preloaded} instances can be costly, so applications should try to
+ * create as few instances as possible. {@link Runfiles#preload()}, but not {@link
+ * Runfiles#preload(java.util.Map)}, returns a single global, softly cached instance of {@link
+ * Runfiles.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
+ * Runfiles.Preloaded#withSourceRepository(String)} or {@link Runfiles.Preloaded#unmapped()} and in
+ * addition to the data in {@link Runfiles.Preloaded} also fix a source repository relative to which
+ * apparent repository names are resolved.
+ *
+ * <p>Creating {@link Runfiles.Preloaded} instances is cheap.
+ */
+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 Runfiles.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 Runfiles.Preloaded} instance.
+     */
+    public final Runfiles unmapped() {
+      return new Runfiles(this, null);
+    }
+
+    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 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(java.util.Map)}.
+   */
+  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.Preloaded} 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 java.io.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
+   */
+  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
+      // faster to use RUNFILES_DIR.
+      return new ManifestBased(getManifestPath(env));
+    } else {
+      return new DirectoryBased(getRunfilesDir(env));
+    }
+  }
+
+  /**
+   * Returns a new {@link Runfiles} instance.
+   *
+   * <p>This method passes the JVM's environment variable map to {@link #create(java.util.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(java.util.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).
+   *
+   * <p>The returned path may not be valid. The caller should check the path's validity and that the
+   * path exists.
+   *
+   * <p>The function may return null. In that case the caller can be sure that the rule does not
+   * know about this data-dependency.
+   *
+   * @param path runfiles-root-relative path of the runfile
+   * @throws IllegalArgumentException if {@code path} fails validation, for example if it's null or
+   *     empty, or not normalized (contains "./", "../", or "//")
+   */
+  public String rlocation(String path) {
+    Util.checkArgument(path != null);
+    Util.checkArgument(!path.isEmpty());
+    Util.checkArgument(
+        !path.startsWith("../")
+            && !path.contains("/..")
+            && !path.startsWith("./")
+            && !path.contains("/./")
+            && !path.endsWith("/.")
+            && !path.contains("//"),
+        "path is not normalized: \"%s\"",
+        path);
+    Util.checkArgument(
+        !path.startsWith("\\"), "path is absolute without a drive letter: \"%s\"", path);
+    if (new File(path).isAbsolute()) {
+      return path;
+    }
+
+    if (sourceRepository == null) {
+      return preloadedRunfiles.rlocationChecked(path);
+    }
+    String[] apparentTargetAndRemainder = path.split("/", 2);
+    if (apparentTargetAndRemainder.length < 2) {
+      return preloadedRunfiles.rlocationChecked(path);
+    }
+    String targetCanonical = getCanonicalRepositoryName(apparentTargetAndRemainder[0]);
+    return preloadedRunfiles.rlocationChecked(
+        targetCanonical + "/" + apparentTargetAndRemainder[1]);
+  }
+
+  /**
+   * Returns environment variables for subprocesses.
+   *
+   * <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 Map<String, String> getEnvVars() {
+    return preloadedRunfiles.getEnvVars();
+  }
+
+  String getCanonicalRepositoryName(String apparentRepositoryName) {
+    return preloadedRunfiles
+        .getRepoMapping()
+        .getOrDefault(
+            new Preloaded.RepoMappingKey(sourceRepository, apparentRepositoryName),
+            apparentRepositoryName);
+  }
+
+  /** Returns true if the platform supports runfiles only via manifests. */
+  private static boolean isManifestOnly(Map<String, String> env) {
+    return "1".equals(env.get("RUNFILES_MANIFEST_ONLY"));
+  }
+
+  private static String getManifestPath(Map<String, String> env) throws IOException {
+    String value = env.get("RUNFILES_MANIFEST_FILE");
+    if (Util.isNullOrEmpty(value)) {
+      throw new IOException(
+          "Cannot load runfiles manifest: $RUNFILES_MANIFEST_ONLY is 1 but"
+              + " $RUNFILES_MANIFEST_FILE is empty or undefined");
+    }
+    return value;
+  }
+
+  private static String getRunfilesDir(Map<String, String> env) throws IOException {
+    String value = env.get("RUNFILES_DIR");
+    if (Util.isNullOrEmpty(value)) {
+      value = env.get("JAVA_RUNFILES");
+    }
+    if (Util.isNullOrEmpty(value)) {
+      throw new IOException(
+          "Cannot find runfiles: $RUNFILES_DIR and $JAVA_RUNFILES are both unset or empty");
+    }
+    return value;
+  }
+
+  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 InputStreamReader(new FileInputStream(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 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);
+      this.repoMapping = loadRepositoryMapping(rlocationChecked("_repo_mapping"));
+    }
+
+    @Override
+    protected String rlocationChecked(String path) {
+      String exactMatch = runfiles.get(path);
+      if (exactMatch != null) {
+        return exactMatch;
+      }
+      // If path references a runfile that lies under a directory that itself is a runfile, then
+      // only the directory is listed in the manifest. Look up all prefixes of path in the manifest
+      // and append the relative path from the prefix if there is a match.
+      int prefixEnd = path.length();
+      while ((prefixEnd = path.lastIndexOf('/', prefixEnd - 1)) != -1) {
+        String prefixMatch = runfiles.get(path.substring(0, prefixEnd));
+        if (prefixMatch != null) {
+          return prefixMatch + '/' + path.substring(prefixEnd + 1);
+        }
+      }
+      return null;
+    }
+
+    @Override
+    protected Map<String, String> getEnvVars() {
+      HashMap<String, String> result = new HashMap<>(4);
+      result.put("RUNFILES_MANIFEST_ONLY", "1");
+      result.put("RUNFILES_MANIFEST_FILE", manifestPath);
+      String runfilesDir = findRunfilesDir(manifestPath);
+      result.put("RUNFILES_DIR", runfilesDir);
+      // TODO(laszlocsomor): remove JAVA_RUNFILES once the Java launcher can pick up RUNFILES_DIR.
+      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;
+        while ((line = r.readLine()) != null) {
+          String runfile;
+          String realPath;
+          if (line.startsWith(" ")) {
+            // In lines starting with a space, the runfile path contains spaces and backslashes
+            // escaped with a backslash. The real path is the rest of the line after the first
+            // unescaped space.
+            int firstSpace = line.indexOf(' ', 1);
+            if (firstSpace == -1) {
+              throw new IOException(
+                  "Invalid runfiles manifest line, expected at least one space after the leading"
+                      + " space: "
+                      + line);
+            }
+            runfile =
+                line.substring(1, firstSpace)
+                    .replace("\\s", " ")
+                    .replace("\\n", "\n")
+                    .replace("\\b", "\\");
+            realPath = line.substring(firstSpace + 1).replace("\\n", "\n").replace("\\b", "\\");
+          } else {
+            int firstSpace = line.indexOf(' ');
+            if (firstSpace == -1) {
+              throw new IOException(
+                  "Invalid runfiles manifest line, expected at least one space: " + line);
+            }
+            runfile = line.substring(0, firstSpace);
+            realPath = line.substring(firstSpace + 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 Preloaded {
+
+    private final String runfilesRoot;
+    private final Map<RepoMappingKey, String> repoMapping;
+
+    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
+    protected String rlocationChecked(String path) {
+      return runfilesRoot + "/" + path;
+    }
+
+    @Override
+    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.
+      result.put("JAVA_RUNFILES", runfilesRoot);
+      return result;
+    }
+  }
+
+  static Preloaded createManifestBasedForTesting(String manifestPath) throws IOException {
+    return new ManifestBased(manifestPath);
+  }
+
+  static Preloaded createDirectoryBasedForTesting(String runfilesDir) throws IOException {
+    return new DirectoryBased(runfilesDir);
+  }
+}
diff --git a/java/runfiles/src/main/java/com/google/devtools/build/runfiles/Util.java b/java/runfiles/src/main/java/com/google/devtools/build/runfiles/Util.java
new file mode 100644
index 0000000..73f0b98
--- /dev/null
+++ b/java/runfiles/src/main/java/com/google/devtools/build/runfiles/Util.java
@@ -0,0 +1,49 @@
+// 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;
+
+/**
+ * Utilities for the other classes in this package.
+ *
+ * <p>These functions are implementations of some basic utilities in the Guava library. We
+ * reimplement these functions instead of depending on Guava, so that the Runfiles library has no
+ * third-party dependencies, thus any Java project can depend on it without the risk of pulling
+ * unwanted or conflicting dependencies (for example if the project already depends on Guava, or
+ * wishes not to depend on it at all).
+ */
+class Util {
+  private Util() {}
+
+  /** Returns true when {@code s} is null or an empty string. */
+  public static boolean isNullOrEmpty(String s) {
+    return s == null || s.isEmpty();
+  }
+
+  /** Throws an {@code IllegalArgumentException} if {@code condition} is false. */
+  public static void checkArgument(boolean condition) {
+    checkArgument(condition, null, null);
+  }
+
+  /** Throws an {@code IllegalArgumentException} if {@code condition} is false. */
+  public static void checkArgument(boolean condition, String error, Object arg1) {
+    if (!condition) {
+      if (isNullOrEmpty(error)) {
+        throw new IllegalArgumentException("argument validation failed");
+      } else {
+        throw new IllegalArgumentException(String.format(error, arg1));
+      }
+    }
+  }
+}
diff --git a/test/repositories.bzl b/test/repositories.bzl
new file mode 100644
index 0000000..5d4f568
--- /dev/null
+++ b/test/repositories.bzl
@@ -0,0 +1,23 @@
+"""Test dependencies for rules_java."""
+
+load("@bazel_skylib//lib:modules.bzl", "modules")
+
+# TODO: Use http_jar from //java:http_jar.bzl once it doesn't refert to cache.bzl from @bazel_tools
+# anymore, which isn't available in Bazel 6.
+load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_file")
+
+def test_repositories():
+    http_file(
+        name = "guava",
+        url = "https://repo1.maven.org/maven2/com/google/guava/guava/33.3.1-jre/guava-33.3.1-jre.jar",
+        integrity = "sha256-S/Dixa+ORSXJbo/eF6T3MH+X+EePEcTI41oOMpiuTpA=",
+        downloaded_file_path = "guava.jar",
+    )
+    http_file(
+        name = "truth",
+        url = "https://repo1.maven.org/maven2/com/google/truth/truth/1.4.4/truth-1.4.4.jar",
+        integrity = "sha256-Ushs3a3DG8hFfB4VaJ/Gt14ul84qg9i1S3ldVW1In4w=",
+        downloaded_file_path = "truth.jar",
+    )
+
+test_repositories_ext = modules.as_extension(test_repositories)
diff --git a/test/runfiles/src/test/java/com/google/devtools/build/runfiles/BUILD.bazel b/test/runfiles/src/test/java/com/google/devtools/build/runfiles/BUILD.bazel
new file mode 100644
index 0000000..67a11ca
--- /dev/null
+++ b/test/runfiles/src/test/java/com/google/devtools/build/runfiles/BUILD.bazel
@@ -0,0 +1,34 @@
+load("@rules_java//java:java_import.bzl", "java_import")
+load("@rules_java//java:java_test.bzl", "java_test")
+
+java_test(
+    name = "RunfilesTest",
+    srcs = ["RunfilesTest.java"],
+    test_class = "com.google.devtools.build.runfiles.RunfilesTest",
+    deps = [
+        ":guava",
+        ":truth",
+        "//java/runfiles",
+    ],
+)
+
+java_test(
+    name = "UtilTest",
+    srcs = ["UtilTest.java"],
+    test_class = "com.google.devtools.build.runfiles.UtilTest",
+    deps = [
+        ":guava",
+        ":truth",
+        "//java/runfiles",
+    ],
+)
+
+java_import(
+    name = "guava",
+    jars = ["@guava//file"],
+)
+
+java_import(
+    name = "truth",
+    jars = ["@truth//file"],
+)
diff --git a/test/runfiles/src/test/java/com/google/devtools/build/runfiles/RunfilesTest.java b/test/runfiles/src/test/java/com/google/devtools/build/runfiles/RunfilesTest.java
new file mode 100644
index 0000000..035a0b5
--- /dev/null
+++ b/test/runfiles/src/test/java/com/google/devtools/build/runfiles/RunfilesTest.java
@@ -0,0 +1,601 @@
+// 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 static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.Path;
+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;
+
+/** Unit tests for {@link Runfiles}. */
+@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 == '\\';
+  }
+
+  private void assertRlocationArg(Runfiles runfiles, String path, @Nullable String error) {
+    IllegalArgumentException e =
+        assertThrows(IllegalArgumentException.class, () -> runfiles.rlocation(path));
+    if (error != null) {
+      assertThat(e).hasMessageThat().contains(error);
+    }
+  }
+
+  @Test
+  public void testRlocationArgumentValidation() throws Exception {
+    Path dir =
+        Files.createTempDirectory(
+            FileSystems.getDefault().getPath(System.getenv("TEST_TMPDIR")), null);
+
+    Runfiles r = Runfiles.create(ImmutableMap.of("RUNFILES_DIR", dir.toString()));
+    assertRlocationArg(r, null, null);
+    assertRlocationArg(r, "", null);
+    assertRlocationArg(r, "../foo", "is not normalized");
+    assertRlocationArg(r, "foo/..", "is not normalized");
+    assertRlocationArg(r, "foo/../bar", "is not normalized");
+    assertRlocationArg(r, "./foo", "is not normalized");
+    assertRlocationArg(r, "foo/.", "is not normalized");
+    assertRlocationArg(r, "foo/./bar", "is not normalized");
+    assertRlocationArg(r, "//foobar", "is not normalized");
+    assertRlocationArg(r, "foo//", "is not normalized");
+    assertRlocationArg(r, "foo//bar", "is not normalized");
+    assertRlocationArg(r, "\\foo", "path is absolute without a drive letter");
+  }
+
+  @Test
+  public void testCreatesManifestBasedRunfiles() throws Exception {
+    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");
+    }
+  }
+
+  @Test
+  public void testCreatesDirectoryBasedRunfiles() throws Exception {
+    Path dir =
+        Files.createTempDirectory(
+            FileSystems.getDefault().getPath(System.getenv("TEST_TMPDIR")), null);
+
+    Runfiles r =
+        Runfiles.create(
+            ImmutableMap.of(
+                "RUNFILES_MANIFEST_FILE", "ignored when RUNFILES_MANIFEST_ONLY is not set to 1",
+                "RUNFILES_DIR", dir.toString(),
+                "JAVA_RUNFILES", "ignored when RUNFILES_DIR has a value",
+                "TEST_SRCDIR", "should always be ignored"));
+    assertThat(r.rlocation("a/b")).endsWith("/a/b");
+    assertThat(r.rlocation("foo")).endsWith("/foo");
+
+    r =
+        Runfiles.create(
+            ImmutableMap.of(
+                "RUNFILES_MANIFEST_FILE", "ignored when RUNFILES_MANIFEST_ONLY is not set to 1",
+                "RUNFILES_DIR", "",
+                "JAVA_RUNFILES", dir.toString(),
+                "TEST_SRCDIR", "should always be ignored"));
+    assertThat(r.rlocation("a/b")).endsWith("/a/b");
+    assertThat(r.rlocation("foo")).endsWith("/foo");
+  }
+
+  @Test
+  public void testIgnoresTestSrcdirWhenJavaRunfilesIsUndefinedAndJustFails() throws Exception {
+    Path dir =
+        Files.createTempDirectory(
+            FileSystems.getDefault().getPath(System.getenv("TEST_TMPDIR")), null);
+
+    Runfiles.create(
+        ImmutableMap.of(
+            "RUNFILES_DIR", dir.toString(),
+            "RUNFILES_MANIFEST_FILE", "ignored when RUNFILES_MANIFEST_ONLY is not set to 1",
+            "TEST_SRCDIR", "should always be ignored"));
+
+    Runfiles.create(
+        ImmutableMap.of(
+            "JAVA_RUNFILES", dir.toString(),
+            "RUNFILES_MANIFEST_FILE", "ignored when RUNFILES_MANIFEST_ONLY is not set to 1",
+            "TEST_SRCDIR", "should always be ignored"));
+
+    IOException e =
+        assertThrows(
+            IOException.class,
+            () ->
+                Runfiles.create(
+                    ImmutableMap.of(
+                        "RUNFILES_DIR",
+                        "",
+                        "JAVA_RUNFILES",
+                        "",
+                        "RUNFILES_MANIFEST_FILE",
+                        "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");
+  }
+
+  @Test
+  public void testFailsToCreateManifestBasedBecauseManifestDoesNotExist() {
+    IOException e =
+        assertThrows(
+            IOException.class,
+            () ->
+                Runfiles.create(
+                    ImmutableMap.of(
+                        "RUNFILES_MANIFEST_ONLY", "1",
+                        "RUNFILES_MANIFEST_FILE", "non-existing path")));
+    assertThat(e).hasMessageThat().contains("non-existing path");
+  }
+
+  @Test
+  public void testManifestBasedEnvVars() throws Exception {
+    Path mf = tempFile("MANIFEST", ImmutableList.of());
+    Map<String, String> envvars =
+        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"))
+            .getEnvVars();
+    assertThat(envvars.keySet())
+        .containsExactly(
+            "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(tempDir.getRoot().toString());
+    assertThat(envvars.get("JAVA_RUNFILES")).isEqualTo(tempDir.getRoot().toString());
+
+    Path rfDir = tempDir.getRoot().toPath().resolve("foo.runfiles");
+    Files.createDirectories(rfDir);
+    mf = tempFile("foo.runfiles_manifest", ImmutableList.of());
+    envvars =
+        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"))
+            .getEnvVars();
+    assertThat(envvars.get("RUNFILES_MANIFEST_ONLY")).isEqualTo("1");
+    assertThat(envvars.get("RUNFILES_MANIFEST_FILE")).isEqualTo(mf.toString());
+    assertThat(envvars.get("RUNFILES_DIR")).isEqualTo(rfDir.toString());
+    assertThat(envvars.get("JAVA_RUNFILES")).isEqualTo(rfDir.toString());
+  }
+
+  @Test
+  public void testDirectoryBasedEnvVars() throws Exception {
+    Map<String, String> envvars =
+        Runfiles.create(
+                ImmutableMap.of(
+                    "RUNFILES_MANIFEST_FILE",
+                    "ignored when RUNFILES_MANIFEST_ONLY is not set to 1",
+                    "RUNFILES_DIR",
+                    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(tempDir.getRoot().toString());
+    assertThat(envvars.get("JAVA_RUNFILES")).isEqualTo(tempDir.getRoot().toString());
+  }
+
+  @Test
+  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()).withSourceRepository("");
+    // Escaping for "\": once for string and once for regex.
+    assertThat(r.rlocation("arg")).matches(".*[/\\\\]mock[/\\\\]runfiles[/\\\\]arg");
+  }
+
+  @Test
+  public void testManifestBasedRlocation() throws Exception {
+    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\\bDirectory",
+                " h/\\si F:\\bjk",
+                " dir\\swith\\sspaces F:\\bj k\\bdir with spaces",
+                " h/\\s\\n\\bi F:\\bjk\\nb"));
+    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\\bDirectory");
+    assertThat(r.rlocation("Foo/Bar/Dir/File")).isEqualTo("E:\\Actual Path\\bDirectory/File");
+    assertThat(r.rlocation("Foo/Bar/Dir/Deeply/Nested/File"))
+        .isEqualTo("E:\\Actual Path\\bDirectory/Deeply/Nested/File");
+    assertThat(r.rlocation("Foo/Bar/Dir/Deeply/Nested/File With Spaces"))
+        .isEqualTo("E:\\Actual Path\\bDirectory/Deeply/Nested/File With Spaces");
+    assertThat(r.rlocation("h/ i")).isEqualTo("F:\\jk");
+    assertThat(r.rlocation("h/ \n\\i")).isEqualTo("F:\\jk\nb");
+    assertThat(r.rlocation("dir with spaces")).isEqualTo("F:\\j k\\dir with spaces");
+    assertThat(r.rlocation("dir with spaces/file")).isEqualTo("F:\\j k\\dir with spaces/file");
+    assertThat(r.rlocation("unknown")).isNull();
+  }
+
+  @Test
+  public void testManifestBasedRlocationWithRepoMapping_fromMain() throws Exception {
+    Path rm =
+        tempFile(
+            "foo.repo_mapping",
+            ImmutableList.of(
+                ",config.json,config.json+1.2.3",
+                ",my_module,_main",
+                ",my_protobuf,protobuf+3.19.2",
+                ",my_workspace,_main",
+                "protobuf+3.19.2,config.json,config.json+1.2.3",
+                "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(
+                ",config.json,config.json+1.2.3",
+                ",my_module,_main",
+                ",my_protobuf,protobuf+3.19.2",
+                ",my_workspace,_main",
+                "protobuf+3.19.2,config.json,config.json+1.2.3",
+                "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(
+                ",config.json,config.json+1.2.3",
+                ",my_module,_main",
+                ",my_protobuf,protobuf+3.19.2",
+                ",my_workspace,_main",
+                "protobuf+3.19.2,config.json,config.json+1.2.3",
+                "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(
+                ",config.json,config.json+1.2.3",
+                ",my_module,_main",
+                ",my_protobuf,protobuf+3.19.2",
+                ",my_workspace,_main",
+                "protobuf+3.19.2,config.json,config.json+1.2.3",
+                "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(
+                ",config.json,config.json+1.2.3",
+                ",my_module,_main",
+                ",my_protobuf,protobuf+3.19.2",
+                ",my_workspace,_main",
+                "protobuf+3.19.2,config.json,config.json+1.2.3",
+                "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(
+                ",config.json,config.json+1.2.3",
+                ",my_module,_main",
+                ",my_protobuf,protobuf+3.19.2",
+                ",my_workspace,_main",
+                "protobuf+3.19.2,config.json,config.json+1.2.3",
+                "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() throws IOException {
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> Runfiles.createDirectoryBasedForTesting(null).withSourceRepository(""));
+
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> Runfiles.createDirectoryBasedForTesting("").withSourceRepository(""));
+
+    assertThrows(
+        IllegalArgumentException.class,
+        () ->
+            Runfiles.createDirectoryBasedForTesting("non-existent directory is bad")
+                .withSourceRepository(""));
+
+    Runfiles unused =
+        Runfiles.createDirectoryBasedForTesting(System.getenv("TEST_TMPDIR"))
+            .withSourceRepository("");
+  }
+
+  @Test
+  public void testManifestBasedCtorArgumentValidation() throws Exception {
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> Runfiles.createManifestBasedForTesting(null).withSourceRepository(""));
+
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> Runfiles.createManifestBasedForTesting("").withSourceRepository(""));
+
+    Path mf = tempFile("foobar", ImmutableList.of("a b"));
+    Runfiles 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);
+  }
+}
diff --git a/test/runfiles/src/test/java/com/google/devtools/build/runfiles/UtilTest.java b/test/runfiles/src/test/java/com/google/devtools/build/runfiles/UtilTest.java
new file mode 100644
index 0000000..3827326
--- /dev/null
+++ b/test/runfiles/src/test/java/com/google/devtools/build/runfiles/UtilTest.java
@@ -0,0 +1,47 @@
+// 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 static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Unit tests for {@link Util}. */
+@RunWith(JUnit4.class)
+public final class UtilTest {
+
+  @Test
+  public void testIsNullOrEmpty() {
+    assertThat(Util.isNullOrEmpty(null)).isTrue();
+    assertThat(Util.isNullOrEmpty("")).isTrue();
+    assertThat(Util.isNullOrEmpty("\0")).isFalse();
+    assertThat(Util.isNullOrEmpty("some text")).isFalse();
+  }
+
+  @Test
+  public void testCheckArgument() {
+    Util.checkArgument(true, null, null);
+
+    IllegalArgumentException e =
+        assertThrows(IllegalArgumentException.class, () -> Util.checkArgument(false, null, null));
+    assertThat(e).hasMessageThat().isEqualTo("argument validation failed");
+
+    e = assertThrows(IllegalArgumentException.class, () -> Util.checkArgument(false, "foo-%s", 42));
+    assertThat(e).hasMessageThat().isEqualTo("foo-42");
+  }
+}