diff --git a/src/main/java/com/google/devtools/build/lib/runtime/CommandLinePathFactory.java b/src/main/java/com/google/devtools/build/lib/runtime/CommandLinePathFactory.java
new file mode 100644
index 00000000000000..e39bf074b2d91c
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/lib/runtime/CommandLinePathFactory.java
@@ -0,0 +1,113 @@
+// 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.lib.runtime;
+
+import com.google.common.base.Preconditions;
+import com.google.common.base.Splitter;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMap;
+import com.google.devtools.build.lib.vfs.FileSystem;
+import com.google.devtools.build.lib.vfs.Path;
+import com.google.devtools.build.lib.vfs.PathFragment;
+import com.google.devtools.build.lib.vfs.Symlinks;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.util.Locale;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Factory for creating {@link PathFragment}s from command-line options.
+ *
+ *
The difference between this and using {@link PathFragment#create(String)} directly is that
+ * this factory replaces values starting with {@code %%} with the corresponding (named) roots
+ * (e.g., {@code %workspace%/foo} becomes {@code /foo}).
+ */
+public final class CommandLinePathFactory {
+ private static final Pattern REPLACEMENT_PATTERN = Pattern.compile("^(%([a-z_]+)%/)?([^%].*)$");
+
+ private static final Splitter PATH_SPLITTER = Splitter.on(File.pathSeparator);
+
+ private final FileSystem fileSystem;
+ private final ImmutableMap roots;
+
+ public CommandLinePathFactory(FileSystem fileSystem, ImmutableMap roots) {
+ this.fileSystem = Preconditions.checkNotNull(fileSystem);
+ this.roots = Preconditions.checkNotNull(roots);
+ }
+
+ /** Creates a {@link Path}. */
+ public Path create(Map env, String value) throws IOException {
+ Preconditions.checkNotNull(env);
+ Preconditions.checkNotNull(value);
+
+ Matcher matcher = REPLACEMENT_PATTERN.matcher(value);
+ Preconditions.checkArgument(matcher.matches());
+
+ String rootName = matcher.group(2);
+ PathFragment path = PathFragment.create(matcher.group(3));
+ if (path.containsUplevelReferences()) {
+ throw new IllegalArgumentException(
+ String.format(
+ Locale.US, "Path must not contain any uplevel references ('..'), got '%s'", value));
+ }
+
+ // Case 1: `path` is relative to a well-known root.
+ if (!Strings.isNullOrEmpty(rootName)) {
+ // The regex above cannot check that `value` is not of form `%foo%//abc` (group 2 will be
+ // `foo` and group 3 will be `/abc`).
+ Preconditions.checkArgument(!path.isAbsolute());
+
+ Path root = roots.get(rootName);
+ if (root == null) {
+ throw new IllegalArgumentException(String.format(Locale.US, "Unknown root %s", rootName));
+ }
+ return root.getRelative(path);
+ }
+
+ // Case 2: `value` is an absolute path.
+ if (path.isAbsolute()) {
+ return fileSystem.getPath(path);
+ }
+
+ // Case 3: `value` is a relative path.
+ //
+ // Since relative paths from the command-line are ambiguous to where they are relative to (i.e.,
+ // relative to the workspace?, the directory Bazel is running in? relative to the `.bazelrc` the
+ // flag is from?), we only allow relative paths with a single segment (i.e., no `/`) and treat
+ // it as relative to the user's `PATH`.
+ if (path.segmentCount() > 1) {
+ throw new IllegalArgumentException(
+ "Path must either be absolute or not contain any path separators");
+ }
+
+ String pathVariable = env.getOrDefault("PATH", "");
+ if (!Strings.isNullOrEmpty(pathVariable)) {
+ for (String lookupPath : PATH_SPLITTER.split(pathVariable)) {
+ Path maybePath = fileSystem.getPath(lookupPath).getRelative(path);
+ if (maybePath.exists(Symlinks.FOLLOW)
+ && maybePath.isFile(Symlinks.FOLLOW)
+ && maybePath.isExecutable()) {
+ return maybePath;
+ }
+ }
+ }
+
+ throw new FileNotFoundException(
+ String.format(
+ Locale.US, "Could not find file with name '%s' on PATH '%s'", path, pathVariable));
+ }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/runtime/CommandLinePathFactoryTest.java b/src/test/java/com/google/devtools/build/lib/runtime/CommandLinePathFactoryTest.java
new file mode 100644
index 00000000000000..223f39b7709923
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/runtime/CommandLinePathFactoryTest.java
@@ -0,0 +1,203 @@
+// 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.lib.runtime;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import com.google.common.base.Joiner;
+import com.google.common.base.Preconditions;
+import com.google.common.collect.ImmutableMap;
+import com.google.devtools.build.lib.vfs.DigestHashFunction;
+import com.google.devtools.build.lib.vfs.FileSystem;
+import com.google.devtools.build.lib.vfs.Path;
+import com.google.devtools.build.lib.vfs.inmemoryfs.InMemoryFileSystem;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.OutputStream;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Tests for {@link CommandLinePathFactory}. */
+@RunWith(JUnit4.class)
+public class CommandLinePathFactoryTest {
+ private static final Joiner PATH_JOINER = Joiner.on(File.pathSeparator);
+
+ private FileSystem filesystem = null;
+
+ @Before
+ public void prepareFilesystem() throws Exception {
+ filesystem = new InMemoryFileSystem(DigestHashFunction.SHA256);
+ }
+
+ private void createExecutable(String path) throws Exception {
+ Preconditions.checkNotNull(path);
+
+ createExecutable(filesystem.getPath(path));
+ }
+
+ private void createExecutable(Path path) throws Exception {
+ Preconditions.checkNotNull(path);
+
+ path.getParentDirectory().createDirectoryAndParents();
+ try (OutputStream stream = path.getOutputStream()) {
+ // Just create an empty file, nothing to do.
+ }
+ path.setExecutable(true);
+ }
+
+ @Test
+ public void emptyPathIsRejected() {
+ CommandLinePathFactory factory = new CommandLinePathFactory(filesystem, ImmutableMap.of());
+
+ assertThrows(IllegalArgumentException.class, () -> factory.create(ImmutableMap.of(), ""));
+ }
+
+ @Test
+ public void createFromAbsolutePath() throws Exception {
+ CommandLinePathFactory factory = new CommandLinePathFactory(filesystem, ImmutableMap.of());
+
+ assertThat(factory.create(ImmutableMap.of(), "/absolute/path/1"))
+ .isEqualTo(filesystem.getPath("/absolute/path/1"));
+ assertThat(factory.create(ImmutableMap.of(), "/absolute/path/2"))
+ .isEqualTo(filesystem.getPath("/absolute/path/2"));
+ }
+
+ @Test
+ public void createWithNamedRoot() throws Exception {
+ CommandLinePathFactory factory =
+ new CommandLinePathFactory(
+ filesystem,
+ ImmutableMap.of(
+ "workspace", filesystem.getPath("/path/to/workspace"),
+ "output_base", filesystem.getPath("/path/to/output/base")));
+
+ assertThat(factory.create(ImmutableMap.of(), "/absolute/path/1"))
+ .isEqualTo(filesystem.getPath("/absolute/path/1"));
+ assertThat(factory.create(ImmutableMap.of(), "/absolute/path/2"))
+ .isEqualTo(filesystem.getPath("/absolute/path/2"));
+
+ assertThat(factory.create(ImmutableMap.of(), "%workspace%/foo"))
+ .isEqualTo(filesystem.getPath("/path/to/workspace/foo"));
+ assertThat(factory.create(ImmutableMap.of(), "%workspace%/foo/bar"))
+ .isEqualTo(filesystem.getPath("/path/to/workspace/foo/bar"));
+
+ assertThat(factory.create(ImmutableMap.of(), "%output_base%/foo"))
+ .isEqualTo(filesystem.getPath("/path/to/output/base/foo"));
+ assertThat(factory.create(ImmutableMap.of(), "%output_base%/foo/bar"))
+ .isEqualTo(filesystem.getPath("/path/to/output/base/foo/bar"));
+ }
+
+ @Test
+ public void pathLeakingOutsideOfRoot() {
+ CommandLinePathFactory factory =
+ new CommandLinePathFactory(
+ filesystem, ImmutableMap.of("a", filesystem.getPath("/path/to/a")));
+
+ assertThrows(
+ IllegalArgumentException.class, () -> factory.create(ImmutableMap.of(), "%a%/../foo"));
+ assertThrows(
+ IllegalArgumentException.class, () -> factory.create(ImmutableMap.of(), "%a%/b/../.."));
+ }
+
+ @Test
+ public void unknownRoot() {
+ CommandLinePathFactory factory =
+ new CommandLinePathFactory(
+ filesystem, ImmutableMap.of("a", filesystem.getPath("/path/to/a")));
+
+ assertThrows(
+ IllegalArgumentException.class, () -> factory.create(ImmutableMap.of(), "%workspace%/foo"));
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> factory.create(ImmutableMap.of(), "%output_base%/foo"));
+ }
+
+ @Test
+ public void rootWithDoubleSlash() {
+ CommandLinePathFactory factory =
+ new CommandLinePathFactory(
+ filesystem, ImmutableMap.of("a", filesystem.getPath("/path/to/a")));
+
+ assertThrows(
+ IllegalArgumentException.class, () -> factory.create(ImmutableMap.of(), "%a%//foo"));
+ }
+
+ @Test
+ public void relativePathWithMultipleSegments() {
+ CommandLinePathFactory factory = new CommandLinePathFactory(filesystem, ImmutableMap.of());
+
+ assertThrows(IllegalArgumentException.class, () -> factory.create(ImmutableMap.of(), "a/b"));
+ assertThrows(
+ IllegalArgumentException.class, () -> factory.create(ImmutableMap.of(), "a/b/c/d"));
+ }
+
+ @Test
+ public void pathLookup() throws Exception {
+ CommandLinePathFactory factory = new CommandLinePathFactory(filesystem, ImmutableMap.of());
+
+ createExecutable("/bin/true");
+ createExecutable("/bin/false");
+ createExecutable("/usr/bin/foo-bar.exe");
+ createExecutable("/usr/local/bin/baz");
+ createExecutable("/home/yannic/bin/abc");
+ createExecutable("/home/yannic/bin/true");
+
+ ImmutableMap env =
+ ImmutableMap.of(
+ "PATH", PATH_JOINER.join("/bin", "/usr/bin", "/usr/local/bin", "/home/yannic/bin"));
+ assertThat(factory.create(env, "true")).isEqualTo(filesystem.getPath("/bin/true"));
+ assertThat(factory.create(env, "false")).isEqualTo(filesystem.getPath("/bin/false"));
+ assertThat(factory.create(env, "foo-bar.exe"))
+ .isEqualTo(filesystem.getPath("/usr/bin/foo-bar.exe"));
+ assertThat(factory.create(env, "baz")).isEqualTo(filesystem.getPath("/usr/local/bin/baz"));
+ assertThat(factory.create(env, "abc")).isEqualTo(filesystem.getPath("/home/yannic/bin/abc"));
+
+ // `.exe` is required.
+ assertThrows(FileNotFoundException.class, () -> factory.create(env, "foo-bar"));
+ }
+
+ @Test
+ public void pathLookupWithUndefinedPath() {
+ CommandLinePathFactory factory = new CommandLinePathFactory(filesystem, ImmutableMap.of());
+
+ assertThrows(FileNotFoundException.class, () -> factory.create(ImmutableMap.of(), "a"));
+ assertThrows(FileNotFoundException.class, () -> factory.create(ImmutableMap.of(), "foo"));
+ }
+
+ @Test
+ public void pathLookupWithNonExistingDirectoryOnPath() {
+ CommandLinePathFactory factory = new CommandLinePathFactory(filesystem, ImmutableMap.of());
+
+ assertThrows(
+ FileNotFoundException.class,
+ () -> factory.create(ImmutableMap.of("PATH", "/does/not/exist"), "a"));
+ }
+
+ @Test
+ public void pathLookupWithExistingAndNonExistingDirectoryOnPath() throws Exception {
+ CommandLinePathFactory factory = new CommandLinePathFactory(filesystem, ImmutableMap.of());
+
+ createExecutable("/bin/foo");
+ createExecutable("/usr/bin/bar");
+ assertThrows(
+ FileNotFoundException.class,
+ () ->
+ factory.create(
+ ImmutableMap.of("PATH", PATH_JOINER.join("/bin", "/does/not/exist", "/usr/bin")),
+ "a"));
+ }
+}