Copy over runfiles library from Bazel (#13)

This uses `root_symlinks` to preserve the fixed runfiles path at which
the library must be available and the private `skip_conflict_checking`
attribute to preserve backwards compatibility by not enabling strict
runfiles path conflict checking.
diff --git a/.bazelci/presubmit.yml b/.bazelci/presubmit.yml
index 19cc938..ab97269 100644
--- a/.bazelci/presubmit.yml
+++ b/.bazelci/presubmit.yml
@@ -8,7 +8,7 @@
     - windows
   bazel:
     - 6.5.0
-    - 7.3.2
+    - 7.4.0
 
 tasks:
   test_module_bzlmod:
diff --git a/WORKSPACE b/WORKSPACE
index e69de29..b8d89d9 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -0,0 +1,7 @@
+workspace(name = "rules_shell")
+
+load("//shell:repositories.bzl", "rules_shell_dependencies", "rules_shell_toolchains")
+
+rules_shell_dependencies()
+
+rules_shell_toolchains()
diff --git a/shell/private/root_symlinks.bzl b/shell/private/root_symlinks.bzl
new file mode 100644
index 0000000..76e0328
--- /dev/null
+++ b/shell/private/root_symlinks.bzl
@@ -0,0 +1,54 @@
+# Copyright 2024 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.
+
+"""Helper rule to preserve the legacy runfiles path for the runfiles lib."""
+
+# Requires the private skip_conflict_checking parameter on ctx.runfiles, which
+# is only available as of Bazel 7.4.0. We only use it when the native shell
+# rules are not available.
+ROOT_SYMLINKS_SUPPORTED = not hasattr(native, "sh_binary")
+
+def _single_file_or_fail(target):
+    files = target[DefaultInfo].files.to_list()
+    if len(files) != 1:
+        fail("Expected exactly one file in {}, got {}".format(target.label, files))
+    return files[0]
+
+def _root_symlinks_impl(ctx):
+    runfiles = ctx.runfiles(
+        root_symlinks = {
+            path: _single_file_or_fail(target)
+            for target, path in ctx.attr.root_symlinks.items()
+        },
+        # Adding root symlinks from Starlark usually enables conflict checking,
+        # but that would break backwards compatibility as it affects all
+        # runfiles, not just the symlinks.
+        skip_conflict_checking = True,
+    )
+    return [
+        DefaultInfo(
+            files = depset(),
+            runfiles = runfiles,
+        ),
+    ]
+
+root_symlinks = rule(
+    implementation = _root_symlinks_impl,
+    attrs = {
+        "root_symlinks": attr.label_keyed_string_dict(
+            allow_files = True,
+            mandatory = True,
+        ),
+    },
+)
diff --git a/shell/runfiles/BUILD b/shell/runfiles/BUILD
new file mode 100644
index 0000000..fb86e50
--- /dev/null
+++ b/shell/runfiles/BUILD
@@ -0,0 +1,22 @@
+load("//shell:sh_library.bzl", "sh_library")
+load("//shell/private:root_symlinks.bzl", "ROOT_SYMLINKS_SUPPORTED", "root_symlinks")
+
+alias(
+    name = "runfiles",
+    actual = ":runfiles_impl" if ROOT_SYMLINKS_SUPPORTED else "@bazel_tools//tools/bash/runfiles",
+    visibility = ["//visibility:public"],
+)
+
+sh_library(
+    name = "runfiles_impl",
+    data = [":runfiles_at_legacy_location"],
+    tags = ["manual"],
+)
+
+root_symlinks(
+    name = "runfiles_at_legacy_location",
+    root_symlinks = {
+        "runfiles.bash": "bazel_tools/tools/bash/runfiles/runfiles.bash",
+    },
+    tags = ["manual"],
+)
diff --git a/shell/runfiles/runfiles.bash b/shell/runfiles/runfiles.bash
new file mode 100644
index 0000000..8e1f944
--- /dev/null
+++ b/shell/runfiles/runfiles.bash
@@ -0,0 +1,468 @@
+# 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.
+
+# Runfiles lookup library for Bazel-built Bash binaries and tests, version 3.
+#
+# VERSION HISTORY:
+# - version 3: Fixes a bug in the init code on macOS and makes the library aware
+#              of Bzlmod repository mappings.
+#   Features:
+#     - With Bzlmod enabled, rlocation now takes the repository mapping of the
+#       Bazel repository containing the calling script into account when
+#       looking up runfiles. The new, optional second argument to rlocation can
+#       be used to specify the canonical name of the Bazel repository to use
+#       instead of this default. The new runfiles_current_repository function
+#       can be used to obtain the canonical name of the N-th caller's Bazel
+#       repository.
+#   Fixed:
+#     - Sourcing a shell script that contains the init code from a shell script
+#       that itself contains the init code no longer fails on macOS.
+#   Compatibility:
+#     - The init script and the runfiles library are backwards and forwards
+#       compatible with version 2.
+# - version 2: Shorter init code.
+#   Features:
+#     - "set -euo pipefail" only at end of init code.
+#       "set -e" breaks the source <path1> || source <path2> || ... scheme on
+#       macOS, because it terminates if path1 does not exist.
+#     - Not exporting any environment variables in init code.
+#       This is now done in runfiles.bash itself.
+#   Compatibility:
+#     - The v1 init code can load the v2 library, i.e. if you have older source
+#       code (still using v1 init) then you can build it with newer Bazel (which
+#       contains the v2 library).
+#     - The reverse is not true: the v2 init code CANNOT load the v1 library,
+#       i.e. if your project (or any of its external dependencies) use v2 init
+#       code, then you need a newer Bazel version (which contains the v2
+#       library).
+# - version 1: Original Bash runfiles library.
+#
+# ENVIRONMENT:
+# - If RUNFILES_LIB_DEBUG=1 is set, the script will print diagnostic messages to
+#   stderr.
+#
+# USAGE:
+# 1.  Depend on this runfiles library from your build rule:
+#
+#       sh_binary(
+#           name = "my_binary",
+#           ...
+#           deps = ["@bazel_tools//tools/bash/runfiles"],
+#       )
+#
+# 2.  Source the runfiles library.
+#
+#     The runfiles library itself defines rlocation which you would need to look
+#     up the library's runtime location, thus we have a chicken-and-egg problem.
+#     Insert the following code snippet to the top of your main script:
+#
+#       # --- begin runfiles.bash initialization v3 ---
+#       # Copy-pasted from the Bazel Bash runfiles library v3.
+#       set -uo pipefail; set +e; f=bazel_tools/tools/bash/runfiles/runfiles.bash
+#       # shellcheck disable=SC1090
+#       source "${RUNFILES_DIR:-/dev/null}/$f" 2>/dev/null || \
+#         source "$(grep -sm1 "^$f " "${RUNFILES_MANIFEST_FILE:-/dev/null}" | cut -f2- -d' ')" 2>/dev/null || \
+#         source "$0.runfiles/$f" 2>/dev/null || \
+#         source "$(grep -sm1 "^$f " "$0.runfiles_manifest" | cut -f2- -d' ')" 2>/dev/null || \
+#         source "$(grep -sm1 "^$f " "$0.exe.runfiles_manifest" | cut -f2- -d' ')" 2>/dev/null || \
+#         { echo>&2 "ERROR: cannot find $f"; exit 1; }; f=; set -e
+#       # --- end runfiles.bash initialization v3 ---
+#
+#
+# 3.  Use rlocation to look up runfile paths.
+#
+#       cat "$(rlocation my_workspace/path/to/my/data.txt)"
+#
+
+if [[ ! -d "${RUNFILES_DIR:-/dev/null}" && ! -f "${RUNFILES_MANIFEST_FILE:-/dev/null}" ]]; then
+  if [[ -f "$0.runfiles_manifest" ]]; then
+    export RUNFILES_MANIFEST_FILE="$0.runfiles_manifest"
+  elif [[ -f "$0.runfiles/MANIFEST" ]]; then
+    export RUNFILES_MANIFEST_FILE="$0.runfiles/MANIFEST"
+  elif [[ -f "$0.runfiles/bazel_tools/tools/bash/runfiles/runfiles.bash" ]]; then
+    export RUNFILES_DIR="$0.runfiles"
+  fi
+fi
+
+case "$(uname -s | tr [:upper:] [:lower:])" in
+msys*|mingw*|cygwin*)
+  # matches an absolute Windows path
+  export _RLOCATION_ISABS_PATTERN="^[a-zA-Z]:[/\\]"
+  # Windows paths are case insensitive and Bazel and MSYS2 capitalize differently, so we can't
+  # assume that all paths are in the same native case.
+  export _RLOCATION_GREP_CASE_INSENSITIVE_ARGS=-i
+  ;;
+*)
+  # matches an absolute Unix path
+  export _RLOCATION_ISABS_PATTERN="^/[^/].*"
+  export _RLOCATION_GREP_CASE_INSENSITIVE_ARGS=
+  ;;
+esac
+
+# Does not exit with a non-zero exit code if no match is found and performs a case-insensitive
+# search on Windows.
+function __runfiles_maybe_grep() {
+  grep $_RLOCATION_GREP_CASE_INSENSITIVE_ARGS "$@" || test $? = 1;
+}
+export -f __runfiles_maybe_grep
+
+# Prints to stdout the runtime location of a data-dependency.
+# The optional second argument can be used to specify the canonical name of the
+# repository whose repository mapping should be used to resolve the repository
+# part of the provided path. If not specified, the repository of the caller is
+# used.
+function rlocation() {
+  if [[ "${RUNFILES_LIB_DEBUG:-}" == 1 ]]; then
+    echo >&2 "INFO[runfiles.bash]: rlocation($1): start"
+  fi
+  if [[ "$1" =~ $_RLOCATION_ISABS_PATTERN ]]; then
+    if [[ "${RUNFILES_LIB_DEBUG:-}" == 1 ]]; then
+      echo >&2 "INFO[runfiles.bash]: rlocation($1): absolute path, return"
+    fi
+    # If the path is absolute, print it as-is.
+    echo "$1"
+    return 0
+  elif [[ "$1" == ../* || "$1" == */.. || "$1" == ./* || "$1" == */./* || "$1" == "*/." || "$1" == *//* ]]; then
+    if [[ "${RUNFILES_LIB_DEBUG:-}" == 1 ]]; then
+      echo >&2 "ERROR[runfiles.bash]: rlocation($1): path is not normalized"
+    fi
+    return 1
+  elif [[ "$1" == \\* ]]; then
+    if [[ "${RUNFILES_LIB_DEBUG:-}" == 1 ]]; then
+      echo >&2 "ERROR[runfiles.bash]: rlocation($1): absolute path without" \
+               "drive name"
+    fi
+    return 1
+  fi
+
+  if [[ -f "$RUNFILES_REPO_MAPPING" ]]; then
+    local -r target_repo_apparent_name=$(echo "$1" | cut -d / -f 1)
+     # Use -s to get an empty remainder if the argument does not contain a slash.
+    # The repo mapping should not be applied to single segment paths, which may
+    # be root symlinks.
+    local -r remainder=$(echo "$1" | cut -s -d / -f 2-)
+    if [[ -n "$remainder" ]]; then
+      if [[ -z "${2+x}" ]]; then
+        local -r source_repo=$(runfiles_current_repository 2)
+      else
+        local -r source_repo=$2
+      fi
+      if [[ "${RUNFILES_LIB_DEBUG:-}" == 1 ]]; then
+        echo >&2 "INFO[runfiles.bash]: rlocation($1): looking up canonical name for ($target_repo_apparent_name) from ($source_repo) in ($RUNFILES_REPO_MAPPING)"
+      fi
+      local -r target_repo=$(__runfiles_maybe_grep -m1 "^$source_repo,$target_repo_apparent_name," "$RUNFILES_REPO_MAPPING" | cut -d , -f 3)
+      if [[ "${RUNFILES_LIB_DEBUG:-}" == 1 ]]; then
+        echo >&2 "INFO[runfiles.bash]: rlocation($1): canonical name of target repo is ($target_repo)"
+      fi
+      if [[ -n "$target_repo" ]]; then
+        local -r rlocation_path="$target_repo/$remainder"
+      else
+        local -r rlocation_path="$1"
+      fi
+    else
+      local -r rlocation_path="$1"
+    fi
+  else
+    if [[ "${RUNFILES_LIB_DEBUG:-}" == 1 ]]; then
+      echo >&2 "INFO[runfiles.bash]: rlocation($1): not using repository mapping ($RUNFILES_REPO_MAPPING) since it does not exist"
+    fi
+    local -r rlocation_path="$1"
+  fi
+
+  runfiles_rlocation_checked "$rlocation_path"
+}
+export -f rlocation
+
+# Exports the environment variables that subprocesses need in order to use
+# runfiles.
+# If a subprocess is a Bazel-built binary rule that also uses the runfiles
+# libraries under @bazel_tools//tools/<lang>/runfiles, then that binary needs
+# these envvars in order to initialize its own runfiles library.
+function runfiles_export_envvars() {
+  if [[ ! -f "${RUNFILES_MANIFEST_FILE:-/dev/null}" \
+        && ! -d "${RUNFILES_DIR:-/dev/null}" ]]; then
+    return 1
+  fi
+
+  if [[ ! -f "${RUNFILES_MANIFEST_FILE:-/dev/null}" ]]; then
+    if [[ -f "$RUNFILES_DIR/MANIFEST" ]]; then
+      export RUNFILES_MANIFEST_FILE="$RUNFILES_DIR/MANIFEST"
+    elif [[ -f "${RUNFILES_DIR}_manifest" ]]; then
+      export RUNFILES_MANIFEST_FILE="${RUNFILES_DIR}_manifest"
+    else
+      export RUNFILES_MANIFEST_FILE=
+    fi
+  elif [[ ! -d "${RUNFILES_DIR:-/dev/null}" ]]; then
+    if [[ "$RUNFILES_MANIFEST_FILE" == */MANIFEST \
+          && -d "${RUNFILES_MANIFEST_FILE%/MANIFEST}" ]]; then
+      export RUNFILES_DIR="${RUNFILES_MANIFEST_FILE%/MANIFEST}"
+      export JAVA_RUNFILES="$RUNFILES_DIR"
+    elif [[ "$RUNFILES_MANIFEST_FILE" == *_manifest \
+          && -d "${RUNFILES_MANIFEST_FILE%_manifest}" ]]; then
+      export RUNFILES_DIR="${RUNFILES_MANIFEST_FILE%_manifest}"
+      export JAVA_RUNFILES="$RUNFILES_DIR"
+    else
+      export RUNFILES_DIR=
+    fi
+  fi
+}
+export -f runfiles_export_envvars
+
+# Returns the canonical name of the Bazel repository containing the script that
+# calls this function.
+# The optional argument N, which defaults to 1, can be used to return the
+# canonical name of the N-th caller instead.
+#
+# Note: This function only works correctly with Bzlmod enabled. Without Bzlmod,
+# its return value is ignored if passed to rlocation.
+function runfiles_current_repository() {
+  local -r idx=${1:-1}
+  local -r raw_caller_path="${BASH_SOURCE[$idx]}"
+  # Make the caller path absolute if needed to handle the case where the script is run directly
+  # from bazel-bin, with working directory a subdirectory of bazel-bin.
+  if [[ "$raw_caller_path" =~ $_RLOCATION_ISABS_PATTERN ]]; then
+    local -r caller_path="$raw_caller_path"
+  else
+    local -r caller_path="$(cd $(dirname "$raw_caller_path"); pwd)/$(basename "$raw_caller_path")"
+  fi
+  if [[ "${RUNFILES_LIB_DEBUG:-}" == 1 ]]; then
+    echo >&2 "INFO[runfiles.bash]: runfiles_current_repository($idx): caller's path is ($caller_path)"
+  fi
+
+  local rlocation_path=
+
+  # If the runfiles manifest exists, search for an entry with target the caller's path.
+  if [[ -f "${RUNFILES_MANIFEST_FILE:-/dev/null}" ]]; then
+    # Escape $caller_path for use in the grep regex below. Also replace \ with / since the manifest
+    # uses / as the path separator even on Windows.
+    local -r normalized_caller_path="$(echo "$caller_path" | sed 's|\\\\*|/|g')"
+    local -r escaped_caller_path="$(echo "$normalized_caller_path" | sed 's/[.[\*^$]/\\&/g')"
+    rlocation_path=$(__runfiles_maybe_grep -m1 "^[^ ]* ${escaped_caller_path}$" "${RUNFILES_MANIFEST_FILE}" | cut -d ' ' -f 1)
+    if [[ -z "$rlocation_path" ]]; then
+      if [[ "${RUNFILES_LIB_DEBUG:-}" == 1 ]]; then
+        echo >&2 "ERROR[runfiles.bash]: runfiles_current_repository($idx): ($normalized_caller_path) is not the target of an entry in the runfiles manifest ($RUNFILES_MANIFEST_FILE)"
+      fi
+      # The binary may also be run directly from bazel-bin or bazel-out.
+      local -r repository=$(echo "$normalized_caller_path" | __runfiles_maybe_grep -E -o '(^|/)(bazel-out/[^/]+/bin|bazel-bin)/external/[^/]+/' | tail -1 | awk -F/ '{print $(NF-1)}')
+      if [[ -n "$repository" ]]; then
+        if [[ "${RUNFILES_LIB_DEBUG:-}" == 1 ]]; then
+          echo >&2 "INFO[runfiles.bash]: runfiles_current_repository($idx): ($normalized_caller_path) lies in repository ($repository) (parsed exec path)"
+        fi
+        echo "$repository"
+      else
+        if [[ "${RUNFILES_LIB_DEBUG:-}" == 1 ]]; then
+          echo >&2 "INFO[runfiles.bash]: runfiles_current_repository($idx): ($normalized_caller_path) lies in the main repository (parsed exec path)"
+        fi
+        echo ""
+      fi
+      return 1
+    else
+      if [[ "${RUNFILES_LIB_DEBUG:-}" == 1 ]]; then
+        echo >&2 "INFO[runfiles.bash]: runfiles_current_repository($idx): ($normalized_caller_path) is the target of ($rlocation_path) in the runfiles manifest"
+      fi
+    fi
+  fi
+
+  # If the runfiles directory exists, check if the caller's path is of the form
+  # $RUNFILES_DIR/rlocation_path and if so, set $rlocation_path.
+  if [[ -z "$rlocation_path" && -d "${RUNFILES_DIR:-/dev/null}" ]]; then
+    normalized_caller_path="$(echo "$caller_path" | sed 's|\\\\*|/|g')"
+    normalized_dir="$(echo "${RUNFILES_DIR%[\/]}" | sed 's|\\\\*|/|g')"
+    if [[ -n "${_RLOCATION_GREP_CASE_INSENSITIVE_ARGS}" ]]; then
+      # When comparing file paths insensitively, also normalize the case of the prefixes.
+      normalized_caller_path=$(echo "$normalized_caller_path" | tr '[:upper:]' '[:lower:]')
+      normalized_dir=$(echo "$normalized_dir" | tr '[:upper:]' '[:lower:]')
+    fi
+    if [[ "$normalized_caller_path" == "$normalized_dir"/* ]]; then
+      rlocation_path=${normalized_caller_path:${#normalized_dir}}
+      rlocation_path=${rlocation_path:1}
+    fi
+    if [[ -z "$rlocation_path" ]]; then
+      if [[ "${RUNFILES_LIB_DEBUG:-}" == 1 ]]; then
+        echo >&2 "INFO[runfiles.bash]: runfiles_current_repository($idx): ($normalized_caller_path) does not lie under the runfiles directory ($normalized_dir)"
+      fi
+      # The only shell script that is not executed from the runfiles directory (if it is populated)
+      # is the sh_binary entrypoint. Parse its path under the execroot, using the last match to
+      # allow for nested execroots (e.g. in Bazel integration tests). The binary may also be run
+      # directly from bazel-bin.
+      local -r repository=$(echo "$normalized_caller_path" | __runfiles_maybe_grep -E -o '(^|/)(bazel-out/[^/]+/bin|bazel-bin)/external/[^/]+/' | tail -1 | awk -F/ '{print $(NF-1)}')
+      if [[ -n "$repository" ]]; then
+        if [[ "${RUNFILES_LIB_DEBUG:-}" == 1 ]]; then
+          echo >&2 "INFO[runfiles.bash]: runfiles_current_repository($idx): ($normalized_caller_path) lies in repository ($repository) (parsed exec path)"
+        fi
+        echo "$repository"
+      else
+        if [[ "${RUNFILES_LIB_DEBUG:-}" == 1 ]]; then
+          echo >&2 "INFO[runfiles.bash]: runfiles_current_repository($idx): ($normalized_caller_path) lies in the main repository (parsed exec path)"
+        fi
+        echo ""
+      fi
+      return 0
+    else
+      if [[ "${RUNFILES_LIB_DEBUG:-}" == 1 ]]; then
+        echo >&2 "INFO[runfiles.bash]: runfiles_current_repository($idx): ($caller_path) has path ($rlocation_path) relative to the runfiles directory ($RUNFILES_DIR)"
+      fi
+    fi
+  fi
+
+  if [[ -z "$rlocation_path" ]]; then
+    if [[ "${RUNFILES_LIB_DEBUG:-}" == 1 ]]; then
+      echo >&2 "ERROR[runfiles.bash]: runfiles_current_repository($idx): cannot determine repository for ($caller_path) since neither the runfiles directory (${RUNFILES_DIR:-}) nor the runfiles manifest (${RUNFILES_MANIFEST_FILE:-}) exist"
+    fi
+    return 1
+  fi
+
+  if [[ "${RUNFILES_LIB_DEBUG:-}" == 1 ]]; then
+    echo >&2 "INFO[runfiles.bash]: runfiles_current_repository($idx): ($caller_path) corresponds to rlocation path ($rlocation_path)"
+  fi
+  # Normalize the rlocation path to be of the form repo/pkg/file.
+  rlocation_path=${rlocation_path#_main/external/}
+  rlocation_path=${rlocation_path#_main/../}
+  local -r repository=$(echo "$rlocation_path" | cut -d / -f 1)
+  if [[ "$repository" == _main ]]; then
+    if [[ "${RUNFILES_LIB_DEBUG:-}" == 1 ]]; then
+      echo >&2 "INFO[runfiles.bash]: runfiles_current_repository($idx): ($rlocation_path) lies in the main repository"
+    fi
+    echo ""
+  else
+    if [[ "${RUNFILES_LIB_DEBUG:-}" == 1 ]]; then
+      echo >&2 "INFO[runfiles.bash]: runfiles_current_repository($idx): ($rlocation_path) lies in repository ($repository)"
+    fi
+    echo "$repository"
+  fi
+}
+export -f runfiles_current_repository
+
+function runfiles_rlocation_checked() {
+  # FIXME: If the runfiles lookup fails, the exit code of this function is 0 if
+  #  and only if the runfiles manifest exists. In particular, the exit code
+  #  behavior is not consistent across platforms.
+  if [[ -e "${RUNFILES_DIR:-/dev/null}/$1" ]]; then
+    if [[ "${RUNFILES_LIB_DEBUG:-}" == 1 ]]; then
+      echo >&2 "INFO[runfiles.bash]: rlocation($1): found under RUNFILES_DIR ($RUNFILES_DIR), return"
+    fi
+    echo "${RUNFILES_DIR}/$1"
+  elif [[ -f "${RUNFILES_MANIFEST_FILE:-/dev/null}" ]]; then
+    if [[ "${RUNFILES_LIB_DEBUG:-}" == 1 ]]; then
+      echo >&2 "INFO[runfiles.bash]: rlocation($1): looking in RUNFILES_MANIFEST_FILE ($RUNFILES_MANIFEST_FILE)"
+    fi
+    # If the rlocation path contains a space or newline, it needs to be prefixed
+    # with a space and spaces, newlines, and backslashes have to be escaped as
+    # \s, \n, and \b.
+    if [[ "$1" == *" "* || "$1" == *$'\n'* ]]; then
+      local search_prefix=" $(echo -n "$1" | sed 's/\\/\\b/g; s/ /\\s/g')"
+      search_prefix="${search_prefix//$'\n'/\\n}"
+      local escaped=true
+      if [[ "${RUNFILES_LIB_DEBUG:-}" == 1 ]]; then
+        echo >&2 "INFO[runfiles.bash]: rlocation($1): using escaped search prefix ($search_prefix)"
+      fi
+    else
+      local search_prefix="$1"
+      local escaped=false
+    fi
+    # The extra space below is added because cut counts from 1.
+    local trim_length=$(echo -n "$search_prefix  " | wc -c | tr -d ' ')
+    # Escape the search prefix for use in the grep regex below *after*
+    # determining the trim length.
+    local result=$(__runfiles_maybe_grep -m1 "^$(echo -n "$search_prefix" | sed 's/[.[\*^$]/\\&/g') " "${RUNFILES_MANIFEST_FILE}" | cut -b "${trim_length}-")
+    if [[ -z "$result" ]]; then
+      # 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.
+      local prefix="$1"
+      local prefix_result=
+      local new_prefix=
+      while true; do
+        new_prefix="${prefix%/*}"
+        [[ "$new_prefix" == "$prefix" ]] && break
+        prefix="$new_prefix"
+        if [[ "${RUNFILES_LIB_DEBUG:-}" == 1 ]]; then
+          echo >&2 "INFO[runfiles.bash]: rlocation($1): looking for prefix ($prefix)"
+        fi
+        if [[ "$prefix" == *" "* || "$prefix" == *$'\n'* ]]; then
+          search_prefix=" $(echo -n "$prefix" | sed 's/\\/\\b/g; s/ /\\s/g')"
+          search_prefix="${search_prefix//$'\n'/\\n}"
+          escaped=true
+          if [[ "${RUNFILES_LIB_DEBUG:-}" == 1 ]]; then
+            echo >&2 "INFO[runfiles.bash]: rlocation($1): using escaped search prefix ($search_prefix)"
+          fi
+        else
+          search_prefix="$prefix"
+          escaped=false
+        fi
+        # The extra space below is added because cut counts from 1.
+        trim_length=$(echo -n "$search_prefix  " | wc -c)
+        prefix_result=$(__runfiles_maybe_grep -m1 "$(echo -n "$search_prefix" | sed 's/[.[\*^$]/\\&/g') " "${RUNFILES_MANIFEST_FILE}" | cut -b ${trim_length}-)
+        if [[ "$escaped" = true ]]; then
+          prefix_result="${prefix_result//\\n/$'\n'}"
+          prefix_result="${prefix_result//\\b/\\}"
+        fi
+        [[ -z "$prefix_result" ]] && continue
+        local -r candidate="${prefix_result}${1#"${prefix}"}"
+        if [[ -e "$candidate" ]]; then
+          if [[ "${RUNFILES_LIB_DEBUG:-}" == 1 ]]; then
+            echo >&2 "INFO[runfiles.bash]: rlocation($1): found in manifest as ($candidate) via prefix ($prefix)"
+          fi
+          echo "$candidate"
+          return 0
+        fi
+        # At this point, the manifest lookup of prefix has been successful,
+        # but the file at the relative path given by the suffix does not
+        # exist. We do not continue the lookup with a shorter prefix for two
+        # reasons:
+        # 1. Manifests generated by Bazel never contain a path that is a
+        #    prefix of another path.
+        # 2. Runfiles libraries for other languages do not check for file
+        #    existence and would have returned the non-existent path. It seems
+        #    better to return no path rather than a potentially different,
+        #    non-empty path.
+        if [[ "${RUNFILES_LIB_DEBUG:-}" == 1 ]]; then
+          echo >&2 "INFO[runfiles.bash]: rlocation($1): found in manifest as ($candidate) via prefix ($prefix), but file does not exist"
+        fi
+        break
+      done
+      if [[ "${RUNFILES_LIB_DEBUG:-}" == 1 ]]; then
+        echo >&2 "INFO[runfiles.bash]: rlocation($1): not found in manifest"
+      fi
+      echo ""
+    else
+      if [[ "$escaped" = true ]]; then
+        result="${result//\\n/$'\n'}"
+        result="${result//\\b/\\}"
+      fi
+      if [[ -e "$result" ]]; then
+        if [[ "${RUNFILES_LIB_DEBUG:-}" == 1 ]]; then
+          echo >&2 "INFO[runfiles.bash]: rlocation($1): found in manifest as ($result)"
+        fi
+        echo "$result"
+      else
+        if [[ "${RUNFILES_LIB_DEBUG:-}" == 1 ]]; then
+          echo >&2 "INFO[runfiles.bash]: rlocation($1): found in manifest as ($result), but file does not exist"
+        fi
+        echo ""
+      fi
+    fi
+  else
+    if [[ "${RUNFILES_LIB_DEBUG:-}" == 1 ]]; then
+      echo >&2 "ERROR[runfiles.bash]: cannot look up runfile \"$1\" " \
+               "(RUNFILES_DIR=\"${RUNFILES_DIR:-}\"," \
+               "RUNFILES_MANIFEST_FILE=\"${RUNFILES_MANIFEST_FILE:-}\")"
+    fi
+    return 1
+  fi
+}
+export -f runfiles_rlocation_checked
+
+export RUNFILES_REPO_MAPPING=$(runfiles_rlocation_checked _repo_mapping 2> /dev/null)
diff --git a/tests/BUILD.bazel b/tests/BUILD.bazel
deleted file mode 100644
index faa7068..0000000
--- a/tests/BUILD.bazel
+++ /dev/null
@@ -1,7 +0,0 @@
-load("//shell:sh_test.bzl", "sh_test")
-
-# Placeholder test to get the release action's `bazel test` to pass.
-sh_test(
-    name = "test",
-    srcs = ["test.sh"],
-)
diff --git a/tests/bcr/BUILD b/tests/bcr/BUILD
index f5f3edd..fb884dd 100644
--- a/tests/bcr/BUILD
+++ b/tests/bcr/BUILD
@@ -6,7 +6,7 @@
     name = "lib",
     srcs = ["lib.sh"],
     data = ["greeting.txt"],
-    deps = ["@bazel_tools//tools/bash/runfiles"],
+    deps = ["@rules_shell//shell/runfiles"],
 )
 
 sh_binary(
@@ -14,7 +14,7 @@
     srcs = ["bin.sh"],
     deps = [
         ":lib",
-        "@bazel_tools//tools/bash/runfiles",
+        "@rules_shell//shell/runfiles",
     ],
 )
 
@@ -22,5 +22,5 @@
     name = "test",
     srcs = ["test.sh"],
     data = [":bin"],
-    deps = ["@bazel_tools//tools/bash/runfiles"],
+    deps = ["@rules_shell//shell/runfiles"],
 )
diff --git a/tests/runfiles/BUILD b/tests/runfiles/BUILD
new file mode 100644
index 0000000..dce2922
--- /dev/null
+++ b/tests/runfiles/BUILD
@@ -0,0 +1,7 @@
+load("//shell:sh_test.bzl", "sh_test")
+
+sh_test(
+    name = "runfiles_test",
+    srcs = ["runfiles_test.bash"],
+    deps = ["//shell/runfiles"],
+)
diff --git a/tests/runfiles/runfiles_test.bash b/tests/runfiles/runfiles_test.bash
new file mode 100755
index 0000000..e50925b
--- /dev/null
+++ b/tests/runfiles/runfiles_test.bash
@@ -0,0 +1,457 @@
+#!/bin/bash
+#
+# 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.
+set -euo pipefail
+
+function _log_base() {
+  prefix=$1
+  shift
+  echo >&2 "${prefix}[$(basename "${BASH_SOURCE[0]}"):${BASH_LINENO[1]} ($(date "+%H:%M:%S %z"))] $*"
+}
+
+function fail() {
+  _log_base "FAILED" "$@"
+  exit 1
+}
+
+function log_fail() {
+  # non-fatal version of fail()
+  _log_base "FAILED" $*
+}
+
+function log_info() {
+  _log_base "INFO" $*
+}
+
+which uname >&/dev/null || fail "cannot locate GNU coreutils"
+
+case "$(uname -s | tr [:upper:] [:lower:])" in
+msys*|mingw*|cygwin*)
+  function is_windows() { true; }
+  ;;
+*)
+  function is_windows() { false; }
+  ;;
+esac
+
+function find_runfiles_lib() {
+  # Unset existing definitions of the functions we want to test.
+  if type rlocation >&/dev/null; then
+    unset rlocation
+    unset runfiles_export_envvars
+  fi
+
+  if [[ ! -d "${RUNFILES_DIR:-/dev/null}" && ! -f "${RUNFILES_MANIFEST_FILE:-/dev/null}" ]]; then
+    if [[ -f "$0.runfiles_manifest" ]]; then
+      export RUNFILES_MANIFEST_FILE="$0.runfiles_manifest"
+    elif [[ -f "$0.runfiles/MANIFEST" ]]; then
+      export RUNFILES_MANIFEST_FILE="$0.runfiles/MANIFEST"
+    elif [[ -f "$0.runfiles/bazel_tools/tools/bash/runfiles/runfiles.bash" ]]; then
+      export RUNFILES_DIR="$0.runfiles"
+    fi
+  fi
+  if [[ -f "${RUNFILES_DIR:-/dev/null}/bazel_tools/tools/bash/runfiles/runfiles.bash" ]]; then
+    echo "${RUNFILES_DIR}/bazel_tools/tools/bash/runfiles/runfiles.bash"
+  elif [[ -f "${RUNFILES_MANIFEST_FILE:-/dev/null}" ]]; then
+    grep -m1 "^bazel_tools/tools/bash/runfiles/runfiles.bash " \
+        "$RUNFILES_MANIFEST_FILE" | cut -d ' ' -f 2-
+  else
+    echo >&2 "ERROR: cannot find //shell/runfiles:runfiles.bash"
+    exit 1
+  fi
+}
+
+function test_rlocation_call_requires_no_envvars() {
+  export RUNFILES_DIR=mock/runfiles
+  export RUNFILES_MANIFEST_FILE=
+  export RUNFILES_MANIFEST_ONLY=
+  source "$runfiles_lib_path" || fail
+}
+
+function test_rlocation_argument_validation() {
+  export RUNFILES_DIR=
+  export RUNFILES_MANIFEST_FILE=
+  export RUNFILES_MANIFEST_ONLY=
+  source "$runfiles_lib_path"
+
+  # Test invalid inputs to make sure rlocation catches these.
+  if rlocation "../foo" >&/dev/null; then
+    fail
+  fi
+  if rlocation "foo/.." >&/dev/null; then
+    fail
+  fi
+  if rlocation "foo/../bar" >&/dev/null; then
+    fail
+  fi
+  if rlocation "./foo" >&/dev/null; then
+    fail
+  fi
+  if rlocation "foo/." >&/dev/null; then
+    fail
+  fi
+  if rlocation "foo/./bar" >&/dev/null; then
+    fail
+  fi
+  if rlocation "//foo" >&/dev/null; then
+    fail
+  fi
+  if rlocation "foo//" >&/dev/null; then
+    fail
+  fi
+  if rlocation "foo//bar" >&/dev/null; then
+    fail
+  fi
+  if rlocation "\\foo" >&/dev/null; then
+    fail
+  fi
+}
+
+function test_rlocation_abs_path() {
+  export RUNFILES_DIR=
+  export RUNFILES_MANIFEST_FILE=
+  export RUNFILES_MANIFEST_ONLY=
+  source "$runfiles_lib_path"
+
+  if is_windows; then
+    [[ "$(rlocation "c:/Foo" || echo failed)" == "c:/Foo" ]] || fail
+    [[ "$(rlocation "c:\\Foo" || echo failed)" == "c:\\Foo" ]] || fail
+  else
+    [[ "$(rlocation "/Foo" || echo failed)" == "/Foo" ]] || fail
+  fi
+}
+
+function test_init_manifest_based_runfiles() {
+  local tmpdir="$(mktemp -d $TEST_TMPDIR/tmp.XXXXXXXX)"
+  cat > $tmpdir/foo.runfiles_manifest << EOF
+a/b $tmpdir/c/d
+e/f $tmpdir/g h
+y $tmpdir/y
+c/dir $tmpdir/dir
+unresolved $tmpdir/unresolved
+ h/\si $tmpdir/ j k
+ h/\s\bi $tmpdir/ j k b
+ h/\n\bi $tmpdir/ \bnj k \na
+ dir\swith\sspaces $tmpdir/dir with spaces
+ space\snewline\nbackslash\b_dir $tmpdir/space newline\nbackslash\ba
+EOF
+  mkdir "${tmpdir}/c"
+  mkdir "${tmpdir}/y"
+  mkdir -p "${tmpdir}/dir/deeply/nested"
+  touch "${tmpdir}/c/d" "${tmpdir}/g h"
+  touch "${tmpdir}/dir/file"
+  ln -s /does/not/exist "${tmpdir}/dir/unresolved"
+  touch "${tmpdir}/dir/deeply/nested/file"
+  touch "${tmpdir}/dir/deeply/nested/file with spaces"
+  ln -s /does/not/exist "${tmpdir}/unresolved"
+  touch "${tmpdir}/ j k"
+  touch "${tmpdir}/ j k b"
+  mkdir -p "${tmpdir}/dir with spaces/nested"
+  touch "${tmpdir}/dir with spaces/nested/file"
+  if ! is_windows; then
+    touch "${tmpdir}/ \nj k "$'\n'a
+    mkdir -p "${tmpdir}/space newline"$'\n'"backslash\a"
+    touch "${tmpdir}/space newline"$'\n'"backslash\a/f i\le"
+  fi
+
+  export RUNFILES_DIR=
+  export RUNFILES_MANIFEST_FILE=$tmpdir/foo.runfiles_manifest
+  source "$runfiles_lib_path"
+
+  [[ -z "$(rlocation a || echo failed)" ]] || fail
+  [[ -z "$(rlocation c/d || echo failed)" ]] || fail
+  [[ "$(rlocation a/b || echo failed)" == "$tmpdir/c/d" ]] || fail
+  [[ "$(rlocation e/f || echo failed)" == "$tmpdir/g h" ]] || fail
+  [[ "$(rlocation y || echo failed)" == "$tmpdir/y" ]] || fail
+  [[ -z "$(rlocation c || echo failed)" ]] || fail
+  [[ -z "$(rlocation c/di || echo failed)" ]] || fail
+  [[ "$(rlocation c/dir || echo failed)" == "$tmpdir/dir" ]] || fail
+  [[ "$(rlocation c/dir/file || echo failed)" == "$tmpdir/dir/file" ]] || fail
+  [[ -z "$(rlocation c/dir/unresolved || echo failed)" ]] || fail
+  [[ "$(rlocation c/dir/deeply/nested/file || echo failed)" == "$tmpdir/dir/deeply/nested/file" ]] || fail
+  [[ "$(rlocation "c/dir/deeply/nested/file with spaces" || echo failed)" == "$tmpdir/dir/deeply/nested/file with spaces" ]] || fail
+  [[ -z "$(rlocation unresolved || echo failed)" ]] || fail
+  [[ "$(rlocation "h/ i" || echo failed)" == "$tmpdir/ j k" ]] || fail
+  [[ "$(rlocation "h/ \i" || echo failed)" == "$tmpdir/ j k b" ]] || fail
+  [[ "$(rlocation "dir with spaces" || echo failed)" == "$tmpdir/dir with spaces" ]] || fail
+  [[ "$(rlocation "dir with spaces/nested/file" || echo failed)" == "$tmpdir/dir with spaces/nested/file" ]] || fail
+  if ! is_windows; then
+    [[ "$(rlocation $'h/\n\\i' || echo failed)" == "$tmpdir/ \nj k "$'\n'a ]] || fail
+    [[ "$(rlocation "space newline"$'\n'"backslash\_dir/f i\le" || echo failed)" == "${tmpdir}/space newline"$'\n'"backslash\a/f i\le" ]] || fail
+  fi
+
+  rm -r "$tmpdir/c/d" "$tmpdir/g h" "$tmpdir/y" "$tmpdir/dir" "$tmpdir/unresolved" "$tmpdir/ j k" "$tmpdir/dir with spaces"
+  if ! is_windows; then
+    rm -r "$tmpdir/ \nj k "$'\n'a "${tmpdir}/space newline"$'\n'"backslash\a"
+    [[ -z "$(rlocation $'h/\n\\i' || echo failed)" ]] || fail
+    [[ -z "$(rlocation "space newline"$'\n'"backslash\_dir/f i\le" || echo failed)" ]] || fail
+  fi
+  [[ -z "$(rlocation a/b || echo failed)" ]] || fail
+  [[ -z "$(rlocation e/f || echo failed)" ]] || fail
+  [[ -z "$(rlocation y || echo failed)" ]] || fail
+  [[ -z "$(rlocation c/dir || echo failed)" ]] || fail
+  [[ -z "$(rlocation c/dir/file || echo failed)" ]] || fail
+  [[ -z "$(rlocation c/dir/deeply/nested/file || echo failed)" ]] || fail
+  [[ -z "$(rlocation "h/ i" || echo failed)" ]] || fail
+  [[ -z "$(rlocation "dir with spaces" || echo failed)" ]] || fail
+  [[ -z "$(rlocation "dir with spaces/nested/file" || echo failed)" ]] || fail
+}
+
+function test_manifest_based_envvars() {
+  local tmpdir="$(mktemp -d $TEST_TMPDIR/tmp.XXXXXXXX)"
+  echo "a b" > $tmpdir/foo.runfiles_manifest
+
+  export RUNFILES_DIR=
+  export RUNFILES_MANIFEST_FILE=$tmpdir/foo.runfiles_manifest
+  mkdir -p $tmpdir/foo.runfiles
+  source "$runfiles_lib_path"
+
+  runfiles_export_envvars
+  [[ "${RUNFILES_DIR:-}" == "$tmpdir/foo.runfiles" ]] || fail
+  [[ "${RUNFILES_MANIFEST_FILE:-}" == "$tmpdir/foo.runfiles_manifest" ]] || fail
+}
+
+function test_init_directory_based_runfiles() {
+  local tmpdir="$(mktemp -d $TEST_TMPDIR/tmp.XXXXXXXX)"
+
+  export RUNFILES_DIR=${tmpdir}/mock/runfiles
+  export RUNFILES_MANIFEST_FILE=
+  source "$runfiles_lib_path"
+
+  mkdir -p "$RUNFILES_DIR/a"
+  touch "$RUNFILES_DIR/a/b" "$RUNFILES_DIR/c d"
+  [[ "$(rlocation a || echo failed)" == "$RUNFILES_DIR/a" ]] || fail
+  [[ "$(rlocation c/d || echo failed)" == failed ]] || fail
+  [[ "$(rlocation a/b || echo failed)" == "$RUNFILES_DIR/a/b" ]] || fail
+  [[ "$(rlocation "c d" || echo failed)" == "$RUNFILES_DIR/c d" ]] || fail
+  [[ "$(rlocation "c" || echo failed)" == failed ]] || fail
+  rm -r "$RUNFILES_DIR/a" "$RUNFILES_DIR/c d"
+  [[ "$(rlocation a || echo failed)" == failed ]] || fail
+  [[ "$(rlocation a/b || echo failed)" == failed ]] || fail
+  [[ "$(rlocation "c d" || echo failed)" == failed ]] || fail
+}
+
+function test_directory_based_runfiles_with_repo_mapping_from_main() {
+  local tmpdir="$(mktemp -d $TEST_TMPDIR/tmp.XXXXXXXX)"
+
+  export RUNFILES_DIR=${tmpdir}/mock/runfiles
+  mkdir -p "$RUNFILES_DIR"
+  cat > "$RUNFILES_DIR/_repo_mapping" <<EOF
+,config.json,config.json+1.2.3
+,my_module,_main
+,my_protobuf,protobuf+3.19.2
+,my_workspace,_main
+protobuf+3.19.2,protobuf,protobuf+3.19.2
+protobuf+3.19.2,config.json,config.json+1.2.3
+EOF
+  export RUNFILES_MANIFEST_FILE=
+  source "$runfiles_lib_path"
+
+  mkdir -p "$RUNFILES_DIR/_main/bar"
+  touch "$RUNFILES_DIR/_main/bar/runfile"
+  mkdir -p "$RUNFILES_DIR/protobuf+3.19.2/bar/dir/de eply/nes ted"
+  touch "$RUNFILES_DIR/protobuf+3.19.2/bar/dir/file"
+  touch "$RUNFILES_DIR/protobuf+3.19.2/bar/dir/de eply/nes ted/fi+le"
+  mkdir -p "$RUNFILES_DIR/protobuf+3.19.2/foo"
+  touch "$RUNFILES_DIR/protobuf+3.19.2/foo/runfile"
+  touch "$RUNFILES_DIR/config.json"
+
+  [[ "$(rlocation "my_module/bar/runfile" "" || echo failed)" == "$RUNFILES_DIR/_main/bar/runfile" ]] || fail
+  [[ "$(rlocation "my_workspace/bar/runfile" "" || echo failed)" == "$RUNFILES_DIR/_main/bar/runfile" ]] || fail
+  [[ "$(rlocation "my_protobuf/foo/runfile" "" || echo failed)" == "$RUNFILES_DIR/protobuf+3.19.2/foo/runfile" ]] || fail
+  [[ "$(rlocation "my_protobuf/bar/dir" "" || echo failed)" == "$RUNFILES_DIR/protobuf+3.19.2/bar/dir" ]] || fail
+  [[ "$(rlocation "my_protobuf/bar/dir/file" "" || echo failed)" == "$RUNFILES_DIR/protobuf+3.19.2/bar/dir/file" ]] || fail
+  [[ "$(rlocation "my_protobuf/bar/dir/de eply/nes ted/fi+le" "" || echo failed)" == "$RUNFILES_DIR/protobuf+3.19.2/bar/dir/de eply/nes ted/fi+le" ]] || fail
+
+  [[ "$(rlocation "protobuf/foo/runfile" "" || echo failed)" == failed ]] || fail
+  [[ "$(rlocation "protobuf/bar/dir/dir/de eply/nes ted/fi+le" "" || echo failed)" == failed ]] || fail
+
+  [[ "$(rlocation "_main/bar/runfile" "" || echo failed)" == "$RUNFILES_DIR/_main/bar/runfile" ]] || fail
+  [[ "$(rlocation "protobuf+3.19.2/foo/runfile" "" || echo failed)" == "$RUNFILES_DIR/protobuf+3.19.2/foo/runfile" ]] || fail
+  [[ "$(rlocation "protobuf+3.19.2/bar/dir" "" || echo failed)" == "$RUNFILES_DIR/protobuf+3.19.2/bar/dir" ]] || fail
+  [[ "$(rlocation "protobuf+3.19.2/bar/dir/file" "" || echo failed)" == "$RUNFILES_DIR/protobuf+3.19.2/bar/dir/file" ]] || fail
+  [[ "$(rlocation "protobuf+3.19.2/bar/dir/de eply/nes ted/fi+le" "" || echo failed)" == "$RUNFILES_DIR/protobuf+3.19.2/bar/dir/de eply/nes ted/fi+le" ]] || fail
+
+  [[ "$(rlocation "config.json" "" || echo failed)" == "$RUNFILES_DIR/config.json" ]] || fail
+}
+
+function test_directory_based_runfiles_with_repo_mapping_from_other_repo() {
+  local tmpdir="$(mktemp -d $TEST_TMPDIR/tmp.XXXXXXXX)"
+
+  export RUNFILES_DIR=${tmpdir}/mock/runfiles
+  mkdir -p "$RUNFILES_DIR"
+  cat > "$RUNFILES_DIR/_repo_mapping" <<EOF
+,config.json,config.json+1.2.3
+,my_module,_main
+,my_protobuf,protobuf+3.19.2
+,my_workspace,_main
+protobuf+3.19.2,protobuf,protobuf+3.19.2
+protobuf+3.19.2,config.json,config.json+1.2.3
+EOF
+  export RUNFILES_MANIFEST_FILE=
+  source "$runfiles_lib_path"
+
+  mkdir -p "$RUNFILES_DIR/_main/bar"
+  touch "$RUNFILES_DIR/_main/bar/runfile"
+  mkdir -p "$RUNFILES_DIR/protobuf+3.19.2/bar/dir/de eply/nes ted"
+  touch "$RUNFILES_DIR/protobuf+3.19.2/bar/dir/file"
+  touch "$RUNFILES_DIR/protobuf+3.19.2/bar/dir/de eply/nes ted/fi+le"
+  mkdir -p "$RUNFILES_DIR/protobuf+3.19.2/foo"
+  touch "$RUNFILES_DIR/protobuf+3.19.2/foo/runfile"
+  touch "$RUNFILES_DIR/config.json"
+
+  [[ "$(rlocation "protobuf/foo/runfile" "protobuf+3.19.2" || echo failed)" == "$RUNFILES_DIR/protobuf+3.19.2/foo/runfile" ]] || fail
+  [[ "$(rlocation "protobuf/bar/dir" "protobuf+3.19.2" || echo failed)" == "$RUNFILES_DIR/protobuf+3.19.2/bar/dir" ]] || fail
+  [[ "$(rlocation "protobuf/bar/dir/file" "protobuf+3.19.2" || echo failed)" == "$RUNFILES_DIR/protobuf+3.19.2/bar/dir/file" ]] || fail
+  [[ "$(rlocation "protobuf/bar/dir/de eply/nes ted/fi+le" "protobuf+3.19.2" || echo failed)" == "$RUNFILES_DIR/protobuf+3.19.2/bar/dir/de eply/nes ted/fi+le" ]] || fail
+
+  [[ "$(rlocation "my_module/bar/runfile" "protobuf+3.19.2" || echo failed)" == failed ]] || fail
+  [[ "$(rlocation "my_protobuf/bar/dir/de eply/nes ted/fi+le" "protobuf+3.19.2" || echo failed)" == failed ]] || fail
+
+  [[ "$(rlocation "_main/bar/runfile" "protobuf+3.19.2" || echo failed)" == "$RUNFILES_DIR/_main/bar/runfile" ]] || fail
+  [[ "$(rlocation "protobuf+3.19.2/foo/runfile" "protobuf+3.19.2" || echo failed)" == "$RUNFILES_DIR/protobuf+3.19.2/foo/runfile" ]] || fail
+  [[ "$(rlocation "protobuf+3.19.2/bar/dir" "protobuf+3.19.2" || echo failed)" == "$RUNFILES_DIR/protobuf+3.19.2/bar/dir" ]] || fail
+  [[ "$(rlocation "protobuf+3.19.2/bar/dir/file" "protobuf+3.19.2" || echo failed)" == "$RUNFILES_DIR/protobuf+3.19.2/bar/dir/file" ]] || fail
+  [[ "$(rlocation "protobuf+3.19.2/bar/dir/de eply/nes ted/fi+le" "protobuf+3.19.2" || echo failed)" == "$RUNFILES_DIR/protobuf+3.19.2/bar/dir/de eply/nes ted/fi+le" ]] || fail
+
+  [[ "$(rlocation "config.json" "protobuf+3.19.2" || echo failed)" == "$RUNFILES_DIR/config.json" ]] || fail
+}
+
+function test_manifest_based_runfiles_with_repo_mapping_from_main() {
+  local tmpdir="$(mktemp -d $TEST_TMPDIR/tmp.XXXXXXXX)"
+
+  cat > "$tmpdir/foo.repo_mapping" <<EOF
+,config.json,config.json+1.2.3
+,my_module,_main
+,my_protobuf,protobuf+3.19.2
+,my_workspace,_main
+protobuf+3.19.2,protobuf,protobuf+3.19.2
+protobuf+3.19.2,config.json,config.json+1.2.3
+EOF
+  export RUNFILES_DIR=
+  export RUNFILES_MANIFEST_FILE="$tmpdir/foo.runfiles_manifest"
+  cat > "$RUNFILES_MANIFEST_FILE" << EOF
+_repo_mapping $tmpdir/foo.repo_mapping
+config.json $tmpdir/config.json
+protobuf+3.19.2/foo/runfile $tmpdir/protobuf+3.19.2/foo/runfile
+_main/bar/runfile $tmpdir/_main/bar/runfile
+protobuf+3.19.2/bar/dir $tmpdir/protobuf+3.19.2/bar/dir
+EOF
+  source "$runfiles_lib_path"
+
+  mkdir -p "$tmpdir/_main/bar"
+  touch "$tmpdir/_main/bar/runfile"
+  mkdir -p "$tmpdir/protobuf+3.19.2/bar/dir/de eply/nes ted"
+  touch "$tmpdir/protobuf+3.19.2/bar/dir/file"
+  touch "$tmpdir/protobuf+3.19.2/bar/dir/de eply/nes ted/fi+le"
+  mkdir -p "$tmpdir/protobuf+3.19.2/foo"
+  touch "$tmpdir/protobuf+3.19.2/foo/runfile"
+  touch "$tmpdir/config.json"
+
+  [[ "$(rlocation "my_module/bar/runfile" "" || echo failed)" == "$tmpdir/_main/bar/runfile" ]] || fail
+  [[ "$(rlocation "my_workspace/bar/runfile" "" || echo failed)" == "$tmpdir/_main/bar/runfile" ]] || fail
+  [[ "$(rlocation "my_protobuf/foo/runfile" "" || echo failed)" == "$tmpdir/protobuf+3.19.2/foo/runfile" ]] || fail
+  [[ "$(rlocation "my_protobuf/bar/dir" "" || echo failed)" == "$tmpdir/protobuf+3.19.2/bar/dir" ]] || fail
+  [[ "$(rlocation "my_protobuf/bar/dir/file" "" || echo failed)" == "$tmpdir/protobuf+3.19.2/bar/dir/file" ]] || fail
+  [[ "$(rlocation "my_protobuf/bar/dir/de eply/nes ted/fi+le" "" || echo failed)" == "$tmpdir/protobuf+3.19.2/bar/dir/de eply/nes ted/fi+le" ]] || fail
+
+  [[ -z "$(rlocation "protobuf/foo/runfile" "" || echo failed)" ]] || fail
+  [[ -z "$(rlocation "protobuf/bar/dir/dir/de eply/nes ted/fi+le" "" || echo failed)" ]] || fail
+
+  [[ "$(rlocation "_main/bar/runfile" "" || echo failed)" == "$tmpdir/_main/bar/runfile" ]] || fail
+  [[ "$(rlocation "protobuf+3.19.2/foo/runfile" "" || echo failed)" == "$tmpdir/protobuf+3.19.2/foo/runfile" ]] || fail
+  [[ "$(rlocation "protobuf+3.19.2/bar/dir" "" || echo failed)" == "$tmpdir/protobuf+3.19.2/bar/dir" ]] || fail
+  [[ "$(rlocation "protobuf+3.19.2/bar/dir/file" "" || echo failed)" == "$tmpdir/protobuf+3.19.2/bar/dir/file" ]] || fail
+  [[ "$(rlocation "protobuf+3.19.2/bar/dir/de eply/nes ted/fi+le" "" || echo failed)" == "$tmpdir/protobuf+3.19.2/bar/dir/de eply/nes ted/fi+le" ]] || fail
+
+  [[ "$(rlocation "config.json" "" || echo failed)" == "$tmpdir/config.json" ]] || fail
+}
+
+function test_manifest_based_runfiles_with_repo_mapping_from_other_repo() {
+  local tmpdir="$(mktemp -d $TEST_TMPDIR/tmp.XXXXXXXX)"
+
+  cat > "$tmpdir/foo.repo_mapping" <<EOF
+,config.json,config.json+1.2.3
+,my_module,_main
+,my_protobuf,protobuf+3.19.2
+,my_workspace,_main
+protobuf+3.19.2,protobuf,protobuf+3.19.2
+protobuf+3.19.2,config.json,config.json+1.2.3
+EOF
+  export RUNFILES_DIR=
+  export RUNFILES_MANIFEST_FILE="$tmpdir/foo.runfiles_manifest"
+  cat > "$RUNFILES_MANIFEST_FILE" << EOF
+_repo_mapping $tmpdir/foo.repo_mapping
+config.json $tmpdir/config.json
+protobuf+3.19.2/foo/runfile $tmpdir/protobuf+3.19.2/foo/runfile
+_main/bar/runfile $tmpdir/_main/bar/runfile
+protobuf+3.19.2/bar/dir $tmpdir/protobuf+3.19.2/bar/dir
+EOF
+  source "$runfiles_lib_path"
+
+  mkdir -p "$tmpdir/_main/bar"
+  touch "$tmpdir/_main/bar/runfile"
+  mkdir -p "$tmpdir/protobuf+3.19.2/bar/dir/de eply/nes ted"
+  touch "$tmpdir/protobuf+3.19.2/bar/dir/file"
+  touch "$tmpdir/protobuf+3.19.2/bar/dir/de eply/nes ted/fi+le"
+  mkdir -p "$tmpdir/protobuf+3.19.2/foo"
+  touch "$tmpdir/protobuf+3.19.2/foo/runfile"
+  touch "$tmpdir/config.json"
+
+  [[ "$(rlocation "protobuf/foo/runfile" "protobuf+3.19.2" || echo failed)" == "$tmpdir/protobuf+3.19.2/foo/runfile" ]] || fail
+  [[ "$(rlocation "protobuf/bar/dir" "protobuf+3.19.2" || echo failed)" == "$tmpdir/protobuf+3.19.2/bar/dir" ]] || fail
+  [[ "$(rlocation "protobuf/bar/dir/file" "protobuf+3.19.2" || echo failed)" == "$tmpdir/protobuf+3.19.2/bar/dir/file" ]] || fail
+  [[ "$(rlocation "protobuf/bar/dir/de eply/nes ted/fi+le" "protobuf+3.19.2" || echo failed)" == "$tmpdir/protobuf+3.19.2/bar/dir/de eply/nes ted/fi+le" ]] || fail
+
+  [[ -z "$(rlocation "my_module/bar/runfile" "protobuf+3.19.2" || echo failed)" ]] || fail
+  [[ -z "$(rlocation "my_protobuf/bar/dir/de eply/nes ted/fi+le" "protobuf+3.19.2" || echo failed)" ]] || fail
+
+  [[ "$(rlocation "_main/bar/runfile" "protobuf+3.19.2" || echo failed)" == "$tmpdir/_main/bar/runfile" ]] || fail
+  [[ "$(rlocation "protobuf+3.19.2/foo/runfile" "protobuf+3.19.2" || echo failed)" == "$tmpdir/protobuf+3.19.2/foo/runfile" ]] || fail
+  [[ "$(rlocation "protobuf+3.19.2/bar/dir" "protobuf+3.19.2" || echo failed)" == "$tmpdir/protobuf+3.19.2/bar/dir" ]] || fail
+  [[ "$(rlocation "protobuf+3.19.2/bar/dir/file" "protobuf+3.19.2" || echo failed)" == "$tmpdir/protobuf+3.19.2/bar/dir/file" ]] || fail
+  [[ "$(rlocation "protobuf+3.19.2/bar/dir/de eply/nes ted/fi+le" "protobuf+3.19.2" || echo failed)" == "$tmpdir/protobuf+3.19.2/bar/dir/de eply/nes ted/fi+le" ]] || fail
+
+  [[ "$(rlocation "config.json" "protobuf+3.19.2" || echo failed)" == "$tmpdir/config.json" ]] || fail
+}
+
+function test_directory_based_envvars() {
+  export RUNFILES_DIR=mock/runfiles
+  export RUNFILES_MANIFEST_FILE=
+  source "$runfiles_lib_path"
+
+  runfiles_export_envvars
+  [[ "${RUNFILES_DIR:-}" == "mock/runfiles" ]] || fail
+  [[ -z "${RUNFILES_MANIFEST_FILE:-}" ]] || fail
+}
+
+function main() {
+  local -r manifest_file="${RUNFILES_MANIFEST_FILE:-}"
+  local -r dir="${RUNFILES_DIR:-}"
+  local -r runfiles_lib_path=$(find_runfiles_lib)
+
+  local -r tests=$(declare -F | grep " -f test" | awk '{print $3}')
+  local failure=0
+  for t in $tests; do
+    export RUNFILES_MANIFEST_FILE="$manifest_file"
+    export RUNFILES_DIR="$dir"
+    if ! ($t); then
+      failure=1
+    fi
+  done
+  return $failure
+}
+
+main
diff --git a/tests/test.sh b/tests/test.sh
deleted file mode 100755
index f1f641a..0000000
--- a/tests/test.sh
+++ /dev/null
@@ -1 +0,0 @@
-#!/usr/bin/env bash