remote: support building merkle trees from Paths

The MerkleTree builder is coupled to ActionInput, a type of the
execution phase. This change adds support for building merkle
trees from Path objects. This is needed for supporting file uploads
in repository_ctx.execute which runs before the execution phase.

Closes #11014.

PiperOrigin-RevId: 303956403
diff --git a/src/main/java/com/google/devtools/build/lib/remote/merkletree/DirectoryTreeBuilder.java b/src/main/java/com/google/devtools/build/lib/remote/merkletree/DirectoryTreeBuilder.java
index da6676c..54bbeef 100644
--- a/src/main/java/com/google/devtools/build/lib/remote/merkletree/DirectoryTreeBuilder.java
+++ b/src/main/java/com/google/devtools/build/lib/remote/merkletree/DirectoryTreeBuilder.java
@@ -37,6 +37,21 @@
 /** Builder for directory trees. */
 class DirectoryTreeBuilder {
 
+  private interface FileNodeVisitor<T> {
+
+    /**
+     * Visits an {@code input} and adds {@link FileNode}s to {@code currDir}.
+     *
+     * <p>This method mutates its parameter {@code currDir}.
+     *
+     * @param input the file or directory to add to {@code currDir}.
+     * @param path the path of {@code input} in the merkle tree.
+     * @param currDir the directory node representing {@code path} in the merkle tree.
+     * @return Returns the number of {@link FileNode}s added to {@code currDir}.
+     */
+    int visit(T input, PathFragment path, DirectoryNode currDir) throws IOException;
+  }
+
   static DirectoryTree fromActionInputs(
       SortedMap<PathFragment, ActionInput> inputs,
       MetadataProvider metadataProvider,
@@ -44,28 +59,135 @@
       DigestUtil digestUtil)
       throws IOException {
     Map<PathFragment, DirectoryNode> tree = new HashMap<>();
-    int numFiles = fromActionInputs(inputs, metadataProvider, execRoot, digestUtil, tree);
+    int numFiles = buildFromActionInputs(inputs, metadataProvider, execRoot, digestUtil, tree);
     return new DirectoryTree(tree, numFiles);
   }
 
-  private static int fromActionInputs(
+  /**
+   * Creates a tree of files and directories from a list of files.
+   *
+   * <p>This method retrieves file metadata from the filesystem. It does not use Bazel's caches.
+   * Thus, don't use this method during the execution phase. Use {@link #fromActionInputs} instead.
+   *
+   * @param inputFiles map of paths to files. The key determines the path at which the file should
+   *     be mounted in the tree.
+   */
+  static DirectoryTree fromPaths(SortedMap<PathFragment, Path> inputFiles, DigestUtil digestUtil)
+      throws IOException {
+    Map<PathFragment, DirectoryNode> tree = new HashMap<>();
+    int numFiles = buildFromPaths(inputFiles, digestUtil, tree);
+    return new DirectoryTree(tree, numFiles);
+  }
+
+  /**
+   * Adds the files in {@code inputs} as nodes to {@code tree}.
+   *
+   * <p>This method mutates {@code tree}.
+   *
+   * @param inputs map of paths to files. The key determines the path at which the file should be
+   *     mounted in the tree.
+   * @return the number of file nodes added to {@code tree}.
+   */
+  private static int buildFromPaths(
+      SortedMap<PathFragment, Path> inputs,
+      DigestUtil digestUtil,
+      Map<PathFragment, DirectoryNode> tree)
+      throws IOException {
+    return build(
+        inputs,
+        tree,
+        (input, path, currDir) -> {
+          if (!input.isFile(Symlinks.NOFOLLOW)) {
+            throw new IOException(String.format("Input '%s' is not a file.", input));
+          }
+          Digest d = digestUtil.compute(input);
+          currDir.addChild(new FileNode(path.getBaseName(), input, d));
+          return 1;
+        });
+  }
+
+  /**
+   * Adds the files in {@code inputs} as nodes to {@code tree}.
+   *
+   * <p>This method mutates {@code tree}.
+   *
+   * @return the number of file nodes added to {@code tree}.
+   */
+  private static int buildFromActionInputs(
       SortedMap<PathFragment, ActionInput> inputs,
       MetadataProvider metadataProvider,
       Path execRoot,
       DigestUtil digestUtil,
       Map<PathFragment, DirectoryNode> tree)
       throws IOException {
+    return build(
+        inputs,
+        tree,
+        (input, path, currDir) -> {
+          if (input instanceof VirtualActionInput) {
+            VirtualActionInput virtualActionInput = (VirtualActionInput) input;
+            Digest d = digestUtil.compute(virtualActionInput);
+            currDir.addChild(new FileNode(path.getBaseName(), virtualActionInput.getBytes(), d));
+            return 1;
+          }
+
+          FileArtifactValue metadata =
+              Preconditions.checkNotNull(
+                  metadataProvider.getMetadata(input),
+                  "missing metadata for '%s'",
+                  input.getExecPathString());
+          switch (metadata.getType()) {
+            case REGULAR_FILE:
+              Digest d = DigestUtil.buildDigest(metadata.getDigest(), metadata.getSize());
+              currDir.addChild(
+                  new FileNode(
+                      path.getBaseName(), ActionInputHelper.toInputPath(input, execRoot), d));
+              return 1;
+
+            case DIRECTORY:
+              SortedMap<PathFragment, ActionInput> directoryInputs =
+                  explodeDirectory(path, execRoot);
+              return buildFromActionInputs(
+                  directoryInputs, metadataProvider, execRoot, digestUtil, tree);
+
+            case SYMLINK:
+              throw new IllegalStateException(
+                  String.format(
+                      "Encountered symlink input '%s', but all"
+                          + " symlinks should have been resolved by SkyFrame. This is a bug.",
+                      path));
+
+            case SPECIAL_FILE:
+              throw new IOException(
+                  String.format(
+                      "The '%s' is a special input which is not supported"
+                          + " by remote caching and execution.",
+                      path));
+
+            case NONEXISTENT:
+              throw new IOException(String.format("The file type of '%s' is not supported.", path));
+          }
+
+          return 0;
+        });
+  }
+
+  private static <T> int build(
+      SortedMap<PathFragment, T> inputs,
+      Map<PathFragment, DirectoryNode> tree,
+      FileNodeVisitor<T> fileNodeVisitor)
+      throws IOException {
     if (inputs.isEmpty()) {
       return 0;
     }
 
     PathFragment dirname = null;
     DirectoryNode dir = null;
-    int numFiles = inputs.size();
-    for (Map.Entry<PathFragment, ActionInput> e : inputs.entrySet()) {
+    int numFiles = 0;
+    for (Map.Entry<PathFragment, T> e : inputs.entrySet()) {
       // Path relative to the exec root
       PathFragment path = e.getKey();
-      ActionInput input = e.getValue();
+      T input = e.getValue();
       if (dirname == null || !path.getParentDirectory().equals(dirname)) {
         dirname = path.getParentDirectory();
         dir = tree.get(dirname);
@@ -76,49 +198,9 @@
         }
       }
 
-      if (input instanceof VirtualActionInput) {
-        VirtualActionInput virtualActionInput = (VirtualActionInput) input;
-        Digest d = digestUtil.compute(virtualActionInput);
-        dir.addChild(new FileNode(path.getBaseName(), virtualActionInput.getBytes(), d));
-        continue;
-      }
-
-      FileArtifactValue metadata =
-          Preconditions.checkNotNull(
-              metadataProvider.getMetadata(input),
-              "missing metadata for '%s'",
-              input.getExecPathString());
-      switch (metadata.getType()) {
-        case REGULAR_FILE:
-          Digest d = DigestUtil.buildDigest(metadata.getDigest(), metadata.getSize());
-          dir.addChild(
-              new FileNode(path.getBaseName(), ActionInputHelper.toInputPath(input, execRoot), d));
-          break;
-
-        case DIRECTORY:
-          SortedMap<PathFragment, ActionInput> directoryInputs = explodeDirectory(path, execRoot);
-          numFiles +=
-              fromActionInputs(directoryInputs, metadataProvider, execRoot, digestUtil, tree);
-          break;
-
-        case SYMLINK:
-          throw new IllegalStateException(
-              String.format(
-                  "Encountered symlink input '%s', but all"
-                      + " symlinks should have been resolved by SkyFrame. This is a bug.",
-                  path));
-
-        case SPECIAL_FILE:
-          throw new IOException(
-              String.format(
-                  "The '%s' is a special input which is not supported"
-                      + " by remote caching and execution.",
-                  path));
-
-        case NONEXISTENT:
-          throw new IOException(String.format("The file type of '%s' is not supported.", path));
-      }
+      numFiles += fileNodeVisitor.visit(input, path, dir);
     }
+
     return numFiles;
   }
 
diff --git a/src/main/java/com/google/devtools/build/lib/remote/merkletree/MerkleTree.java b/src/main/java/com/google/devtools/build/lib/remote/merkletree/MerkleTree.java
index 918e988..7115601 100644
--- a/src/main/java/com/google/devtools/build/lib/remote/merkletree/MerkleTree.java
+++ b/src/main/java/com/google/devtools/build/lib/remote/merkletree/MerkleTree.java
@@ -118,13 +118,28 @@
       Path execRoot,
       DigestUtil digestUtil)
       throws IOException {
-    try (SilentCloseable c = Profiler.instance().profile("MerkleTree.build")) {
+    try (SilentCloseable c = Profiler.instance().profile("MerkleTree.build(ActionInput)")) {
       DirectoryTree tree =
           DirectoryTreeBuilder.fromActionInputs(inputs, metadataProvider, execRoot, digestUtil);
       return build(tree, digestUtil);
     }
   }
 
+  /**
+   * Constructs a merkle tree from a lexicographically sorted map of files.
+   *
+   * @param inputFiles a map of path to files. The map is required to be sorted lexicographically by
+   *     paths.
+   * @param digestUtil a hashing utility
+   */
+  public static MerkleTree build(SortedMap<PathFragment, Path> inputFiles, DigestUtil digestUtil)
+      throws IOException {
+    try (SilentCloseable c = Profiler.instance().profile("MerkleTree.build(Path)")) {
+      DirectoryTree tree = DirectoryTreeBuilder.fromPaths(inputFiles, digestUtil);
+      return build(tree, digestUtil);
+    }
+  }
+
   private static MerkleTree build(DirectoryTree tree, DigestUtil digestUtil) {
     Preconditions.checkNotNull(tree);
     if (tree.isEmpty()) {
diff --git a/src/test/java/com/google/devtools/build/lib/remote/merkletree/ActionInputDirectoryTreeTest.java b/src/test/java/com/google/devtools/build/lib/remote/merkletree/ActionInputDirectoryTreeTest.java
new file mode 100644
index 0000000..79408f4
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/remote/merkletree/ActionInputDirectoryTreeTest.java
@@ -0,0 +1,156 @@
+// Copyright 2020 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.remote.merkletree;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.devtools.build.lib.actions.ActionInput;
+import com.google.devtools.build.lib.actions.ActionInputHelper;
+import com.google.devtools.build.lib.actions.Artifact;
+import com.google.devtools.build.lib.actions.FileArtifactValue;
+import com.google.devtools.build.lib.actions.cache.VirtualActionInput;
+import com.google.devtools.build.lib.actions.util.ActionsTestUtil;
+import com.google.devtools.build.lib.remote.merkletree.DirectoryTree.FileNode;
+import com.google.devtools.build.lib.remote.util.StaticMetadataProvider;
+import com.google.devtools.build.lib.remote.util.StringActionInput;
+import com.google.devtools.build.lib.vfs.FileSystemUtils;
+import com.google.devtools.build.lib.vfs.Path;
+import com.google.devtools.build.lib.vfs.PathFragment;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.TreeMap;
+import org.junit.Test;
+
+/** Tests for {@link DirectoryTreeBuilder#buildFromActionInputs}. */
+public class ActionInputDirectoryTreeTest extends DirectoryTreeTest {
+
+  @Override
+  protected DirectoryTree build(Path... paths) throws IOException {
+    SortedMap<PathFragment, ActionInput> inputFiles = new TreeMap<>();
+    Map<ActionInput, FileArtifactValue> metadata = new HashMap<>();
+
+    for (Path path : paths) {
+      PathFragment relPath = path.relativeTo(execRoot);
+      Artifact a = ActionsTestUtil.createArtifact(artifactRoot, path);
+
+      inputFiles.put(relPath, a);
+      metadata.put(a, FileArtifactValue.createForTesting(a));
+    }
+
+    return DirectoryTreeBuilder.fromActionInputs(
+        inputFiles, new StaticMetadataProvider(metadata), execRoot, digestUtil);
+  }
+
+  @Test
+  public void virtualActionInputShouldWork() throws Exception {
+    SortedMap<PathFragment, ActionInput> sortedInputs = new TreeMap<>();
+    Map<ActionInput, FileArtifactValue> metadata = new HashMap<>();
+
+    Artifact foo = addFile("srcs/foo.cc", "foo", sortedInputs, metadata);
+    VirtualActionInput bar = addVirtualFile("srcs/bar.cc", "bar", sortedInputs);
+
+    DirectoryTree tree =
+        DirectoryTreeBuilder.fromActionInputs(
+            sortedInputs, new StaticMetadataProvider(metadata), execRoot, digestUtil);
+    assertLexicographicalOrder(tree);
+
+    assertThat(directoriesAtDepth(0, tree)).containsExactly("srcs");
+    assertThat(directoriesAtDepth(1, tree)).isEmpty();
+
+    FileNode expectedFooNode =
+        new FileNode("foo.cc", foo.getPath(), digestUtil.computeAsUtf8("foo"));
+    FileNode expectedBarNode =
+        new FileNode("bar.cc", bar.getBytes(), digestUtil.computeAsUtf8("bar"));
+    assertThat(fileNodesAtDepth(tree, 0)).isEmpty();
+    assertThat(fileNodesAtDepth(tree, 1)).containsExactly(expectedFooNode, expectedBarNode);
+  }
+
+  @Test
+  public void directoryInputShouldBeExpanded() throws Exception {
+    // Test that directory inputs are fully expanded and added to the input tree.
+    // Note that this test is not about tree artifacts, but normal artifacts that point to
+    // a directory on disk.
+
+    SortedMap<PathFragment, ActionInput> sortedInputs = new TreeMap<>();
+    Map<ActionInput, FileArtifactValue> metadata = new HashMap<>();
+
+    Artifact foo = addFile("srcs/foo.cc", "foo", sortedInputs, metadata);
+
+    Path dirPath = execRoot.getRelative("srcs/dir");
+    dirPath.createDirectoryAndParents();
+
+    Path barPath = dirPath.getRelative("bar.cc");
+    FileSystemUtils.writeContentAsLatin1(barPath, "bar");
+    ActionInput bar = ActionInputHelper.fromPath(barPath.relativeTo(execRoot));
+    metadata.put(bar, FileArtifactValue.createForTesting(barPath));
+
+    dirPath.getRelative("fizz").createDirectoryAndParents();
+    Path buzzPath = dirPath.getRelative("fizz/buzz.cc");
+    FileSystemUtils.writeContentAsLatin1(dirPath.getRelative("fizz/buzz.cc"), "buzz");
+    ActionInput buzz = ActionInputHelper.fromPath(buzzPath.relativeTo(execRoot));
+    metadata.put(buzz, FileArtifactValue.createForTesting(buzzPath));
+
+    Artifact dir = ActionsTestUtil.createArtifact(artifactRoot, dirPath);
+    sortedInputs.put(dirPath.relativeTo(execRoot), dir);
+    metadata.put(dir, FileArtifactValue.createForTesting(dirPath));
+
+    DirectoryTree tree =
+        DirectoryTreeBuilder.fromActionInputs(
+            sortedInputs, new StaticMetadataProvider(metadata), execRoot, digestUtil);
+    assertLexicographicalOrder(tree);
+
+    assertThat(directoriesAtDepth(0, tree)).containsExactly("srcs");
+    assertThat(directoriesAtDepth(1, tree)).containsExactly("dir");
+    assertThat(directoriesAtDepth(2, tree)).containsExactly("fizz");
+    assertThat(directoriesAtDepth(3, tree)).isEmpty();
+
+    FileNode expectedFooNode =
+        new FileNode("foo.cc", foo.getPath(), digestUtil.computeAsUtf8("foo"));
+    FileNode expectedBarNode =
+        new FileNode(
+            "bar.cc", execRoot.getRelative(bar.getExecPath()), digestUtil.computeAsUtf8("bar"));
+    FileNode expectedBuzzNode =
+        new FileNode(
+            "buzz.cc", execRoot.getRelative(buzz.getExecPath()), digestUtil.computeAsUtf8("buzz"));
+    assertThat(fileNodesAtDepth(tree, 0)).isEmpty();
+    assertThat(fileNodesAtDepth(tree, 1)).containsExactly(expectedFooNode);
+    assertThat(fileNodesAtDepth(tree, 2)).containsExactly(expectedBarNode);
+    assertThat(fileNodesAtDepth(tree, 3)).containsExactly(expectedBuzzNode);
+  }
+
+  private static VirtualActionInput addVirtualFile(
+      String path, String content, SortedMap<PathFragment, ActionInput> sortedInputs) {
+    VirtualActionInput input = new StringActionInput(content, PathFragment.create(path));
+    sortedInputs.put(PathFragment.create(path), input);
+    return input;
+  }
+
+  private Artifact addFile(
+      String path,
+      String content,
+      SortedMap<PathFragment, ActionInput> sortedInputs,
+      Map<ActionInput, FileArtifactValue> metadata)
+      throws IOException {
+    Path p = execRoot.getRelative(path);
+    p.getParentDirectory().createDirectoryAndParents();
+    FileSystemUtils.writeContentAsLatin1(p, content);
+    Artifact a = ActionsTestUtil.createArtifact(artifactRoot, p);
+
+    sortedInputs.put(PathFragment.create(path), a);
+    metadata.put(a, FileArtifactValue.createForTesting(a));
+    return a;
+  }
+}
diff --git a/src/test/java/com/google/devtools/build/lib/remote/merkletree/DirectoryTreeTest.java b/src/test/java/com/google/devtools/build/lib/remote/merkletree/DirectoryTreeTest.java
index bd478f4..33b1c36 100644
--- a/src/test/java/com/google/devtools/build/lib/remote/merkletree/DirectoryTreeTest.java
+++ b/src/test/java/com/google/devtools/build/lib/remote/merkletree/DirectoryTreeTest.java
@@ -15,20 +15,12 @@
 
 import static com.google.common.truth.Truth.assertThat;
 
-import com.google.devtools.build.lib.actions.ActionInput;
-import com.google.devtools.build.lib.actions.ActionInputHelper;
-import com.google.devtools.build.lib.actions.Artifact;
 import com.google.devtools.build.lib.actions.ArtifactRoot;
-import com.google.devtools.build.lib.actions.FileArtifactValue;
-import com.google.devtools.build.lib.actions.cache.VirtualActionInput;
-import com.google.devtools.build.lib.actions.util.ActionsTestUtil;
 import com.google.devtools.build.lib.clock.JavaClock;
 import com.google.devtools.build.lib.remote.merkletree.DirectoryTree.DirectoryNode;
 import com.google.devtools.build.lib.remote.merkletree.DirectoryTree.FileNode;
 import com.google.devtools.build.lib.remote.merkletree.DirectoryTree.Node;
 import com.google.devtools.build.lib.remote.util.DigestUtil;
-import com.google.devtools.build.lib.remote.util.StaticMetadataProvider;
-import com.google.devtools.build.lib.remote.util.StringActionInput;
 import com.google.devtools.build.lib.vfs.DigestHashFunction;
 import com.google.devtools.build.lib.vfs.FileSystem;
 import com.google.devtools.build.lib.vfs.FileSystemUtils;
@@ -37,12 +29,7 @@
 import com.google.devtools.build.lib.vfs.inmemoryfs.InMemoryFileSystem;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
 import java.util.List;
-import java.util.Map;
-import java.util.SortedMap;
-import java.util.TreeMap;
 import java.util.stream.Collectors;
 import org.junit.Before;
 import org.junit.Test;
@@ -51,11 +38,11 @@
 
 /** Unit tests for {@link DirectoryTree}. */
 @RunWith(JUnit4.class)
-public class DirectoryTreeTest {
+public abstract class DirectoryTreeTest {
 
-  private Path execRoot;
-  private ArtifactRoot artifactRoot;
-  private DigestUtil digestUtil;
+  protected Path execRoot;
+  protected ArtifactRoot artifactRoot;
+  protected DigestUtil digestUtil;
 
   @Before
   public void setup() {
@@ -65,123 +52,36 @@
     digestUtil = new DigestUtil(fs.getDigestFunction());
   }
 
+  protected abstract DirectoryTree build(Path... paths) throws IOException;
+
   @Test
   public void emptyTreeShouldWork() throws Exception {
-    DirectoryTree tree =
-        DirectoryTreeBuilder.fromActionInputs(
-            new TreeMap<>(),
-            new StaticMetadataProvider(Collections.emptyMap()),
-            execRoot,
-            digestUtil);
+    DirectoryTree tree = build();
     assertThat(directoryNodesAtDepth(tree, 0)).isEmpty();
     assertThat(fileNodesAtDepth(tree, 0)).isEmpty();
   }
 
   @Test
   public void buildingATreeOfFilesShouldWork() throws Exception {
-    SortedMap<PathFragment, ActionInput> sortedInputs = new TreeMap<>();
-    Map<ActionInput, FileArtifactValue> metadata = new HashMap<>();
+    Path foo = createFile("srcs/foo.cc", "foo");
+    Path bar = createFile("srcs/bar.cc", "bar");
+    Path buzz = createFile("srcs/fizz/buzz.cc", "buzz");
 
-    Artifact foo = addFile("srcs/foo.cc", "foo", sortedInputs, metadata);
-    Artifact bar = addFile("srcs/bar.cc", "bar", sortedInputs, metadata);
-    Artifact buzz = addFile("srcs/fizz/buzz.cc", "buzz", sortedInputs, metadata);
-
-    DirectoryTree tree =
-        DirectoryTreeBuilder.fromActionInputs(
-            sortedInputs, new StaticMetadataProvider(metadata), execRoot, digestUtil);
+    DirectoryTree tree = build(foo, bar, buzz);
     assertLexicographicalOrder(tree);
 
     assertThat(directoriesAtDepth(0, tree)).containsExactly("srcs");
     assertThat(directoriesAtDepth(1, tree)).containsExactly("fizz");
     assertThat(directoriesAtDepth(2, tree)).isEmpty();
 
-    FileNode expectedFooNode =
-        new FileNode("foo.cc", foo.getPath(), digestUtil.computeAsUtf8("foo"));
-    FileNode expectedBarNode =
-        new FileNode("bar.cc", bar.getPath(), digestUtil.computeAsUtf8("bar"));
-    FileNode expectedBuzzNode =
-        new FileNode("buzz.cc", buzz.getPath(), digestUtil.computeAsUtf8("buzz"));
+    FileNode expectedFooNode = new FileNode("foo.cc", foo, digestUtil.computeAsUtf8("foo"));
+    FileNode expectedBarNode = new FileNode("bar.cc", bar, digestUtil.computeAsUtf8("bar"));
+    FileNode expectedBuzzNode = new FileNode("buzz.cc", buzz, digestUtil.computeAsUtf8("buzz"));
     assertThat(fileNodesAtDepth(tree, 0)).isEmpty();
     assertThat(fileNodesAtDepth(tree, 1)).containsExactly(expectedFooNode, expectedBarNode);
     assertThat(fileNodesAtDepth(tree, 2)).containsExactly(expectedBuzzNode);
   }
 
-  @Test
-  public void virtualActionInputShouldWork() throws Exception {
-    SortedMap<PathFragment, ActionInput> sortedInputs = new TreeMap<>();
-    Map<ActionInput, FileArtifactValue> metadata = new HashMap<>();
-
-    Artifact foo = addFile("srcs/foo.cc", "foo", sortedInputs, metadata);
-    VirtualActionInput bar = addVirtualFile("srcs/bar.cc", "bar", sortedInputs);
-
-    DirectoryTree tree =
-        DirectoryTreeBuilder.fromActionInputs(
-            sortedInputs, new StaticMetadataProvider(metadata), execRoot, digestUtil);
-    assertLexicographicalOrder(tree);
-
-    assertThat(directoriesAtDepth(0, tree)).containsExactly("srcs");
-    assertThat(directoriesAtDepth(1, tree)).isEmpty();
-
-    FileNode expectedFooNode =
-        new FileNode("foo.cc", foo.getPath(), digestUtil.computeAsUtf8("foo"));
-    FileNode expectedBarNode =
-        new FileNode("bar.cc", bar.getBytes(), digestUtil.computeAsUtf8("bar"));
-    assertThat(fileNodesAtDepth(tree, 0)).isEmpty();
-    assertThat(fileNodesAtDepth(tree, 1)).containsExactly(expectedFooNode, expectedBarNode);
-  }
-
-  @Test
-  public void directoryInputShouldBeExpanded() throws Exception {
-    // Test that directory inputs are fully expanded and added to the input tree.
-    // Note that this test is not about tree artifacts, but normal artifacts that point to
-    // a directory on disk.
-
-    SortedMap<PathFragment, ActionInput> sortedInputs = new TreeMap<>();
-    Map<ActionInput, FileArtifactValue> metadata = new HashMap<>();
-
-    Artifact foo = addFile("srcs/foo.cc", "foo", sortedInputs, metadata);
-
-    Path dirPath = execRoot.getRelative("srcs/dir");
-    dirPath.createDirectoryAndParents();
-
-    Path barPath = dirPath.getRelative("bar.cc");
-    FileSystemUtils.writeContentAsLatin1(barPath, "bar");
-    ActionInput bar = ActionInputHelper.fromPath(barPath.relativeTo(execRoot));
-    metadata.put(bar, FileArtifactValue.createForTesting(barPath));
-
-    dirPath.getRelative("fizz").createDirectoryAndParents();
-    Path buzzPath = dirPath.getRelative("fizz/buzz.cc");
-    FileSystemUtils.writeContentAsLatin1(dirPath.getRelative("fizz/buzz.cc"), "buzz");
-    ActionInput buzz = ActionInputHelper.fromPath(buzzPath.relativeTo(execRoot));
-    metadata.put(buzz, FileArtifactValue.createForTesting(buzzPath));
-
-    Artifact dir = ActionsTestUtil.createArtifact(artifactRoot, dirPath);
-    sortedInputs.put(dirPath.relativeTo(execRoot), dir);
-    metadata.put(dir, FileArtifactValue.createForTesting(dirPath));
-
-    DirectoryTree tree =
-        DirectoryTreeBuilder.fromActionInputs(
-            sortedInputs, new StaticMetadataProvider(metadata), execRoot, digestUtil);
-    assertLexicographicalOrder(tree);
-
-    assertThat(directoriesAtDepth(0, tree)).containsExactly("srcs");
-    assertThat(directoriesAtDepth(1, tree)).containsExactly("dir");
-    assertThat(directoriesAtDepth(2, tree)).containsExactly("fizz");
-    assertThat(directoriesAtDepth(3, tree)).isEmpty();
-
-    FileNode expectedFooNode =
-        new FileNode("foo.cc", foo.getPath(), digestUtil.computeAsUtf8("foo"));
-    FileNode expectedBarNode =
-        new FileNode(
-            "bar.cc", execRoot.getRelative(bar.getExecPath()), digestUtil.computeAsUtf8("bar"));
-    FileNode expectedBuzzNode =
-        new FileNode(
-            "buzz.cc", execRoot.getRelative(buzz.getExecPath()), digestUtil.computeAsUtf8("buzz"));
-    assertThat(fileNodesAtDepth(tree, 0)).isEmpty();
-    assertThat(fileNodesAtDepth(tree, 1)).containsExactly(expectedFooNode);
-    assertThat(fileNodesAtDepth(tree, 2)).containsExactly(expectedBarNode);
-    assertThat(fileNodesAtDepth(tree, 3)).containsExactly(expectedBuzzNode);
-  }
 
   @Test
   public void testLexicographicalOrder() throws Exception {
@@ -196,42 +96,22 @@
     //
     // However, the tree node [system-root, system] is not (note the missing / suffix).
 
-    SortedMap<PathFragment, ActionInput> sortedInputs = new TreeMap<>();
-    Map<ActionInput, FileArtifactValue> metadata = new HashMap<>();
+    Path file1 = createFile("srcs/system/foo.txt", "foo");
+    Path file2 = createFile("srcs/system-root/bar.txt", "bar");
 
-    addFile("srcs/system/foo.txt", "foo", sortedInputs, metadata);
-    addFile("srcs/system-root/bar.txt", "bar", sortedInputs, metadata);
+    DirectoryTree tree = build(file1, file2);
 
-    DirectoryTree tree =
-        DirectoryTreeBuilder.fromActionInputs(
-            sortedInputs, new StaticMetadataProvider(metadata), execRoot, digestUtil);
     assertLexicographicalOrder(tree);
   }
 
-  private Artifact addFile(
-      String path,
-      String content,
-      SortedMap<PathFragment, ActionInput> sortedInputs,
-      Map<ActionInput, FileArtifactValue> metadata)
-      throws IOException {
+  protected Path createFile(String path, String content) throws IOException {
     Path p = execRoot.getRelative(path);
     p.getParentDirectory().createDirectoryAndParents();
     FileSystemUtils.writeContentAsLatin1(p, content);
-    Artifact a = ActionsTestUtil.createArtifact(artifactRoot, p);
-
-    sortedInputs.put(PathFragment.create(path), a);
-    metadata.put(a, FileArtifactValue.createForTesting(a));
-    return a;
+    return p;
   }
 
-  private VirtualActionInput addVirtualFile(
-      String path, String content, SortedMap<PathFragment, ActionInput> sortedInputs) {
-    VirtualActionInput input = new StringActionInput(content, PathFragment.create(path));
-    sortedInputs.put(PathFragment.create(path), input);
-    return input;
-  }
-
-  private static void assertLexicographicalOrder(DirectoryTree tree) {
+  static void assertLexicographicalOrder(DirectoryTree tree) {
     // Assert the lexicographical order as defined by the remote execution protocol
     tree.visit(
         (PathFragment dirname, List<FileNode> files, List<DirectoryNode> dirs) -> {
@@ -240,7 +120,7 @@
         });
   }
 
-  private static List<String> directoriesAtDepth(int depth, DirectoryTree tree) {
+  static List<String> directoriesAtDepth(int depth, DirectoryTree tree) {
     return asPathSegments(directoryNodesAtDepth(tree, depth));
   }
 
@@ -260,7 +140,7 @@
     return directoryNodes;
   }
 
-  private static List<FileNode> fileNodesAtDepth(DirectoryTree tree, int depth) {
+  static List<FileNode> fileNodesAtDepth(DirectoryTree tree, int depth) {
     List<FileNode> fileNodes = new ArrayList<>();
     tree.visit(
         (PathFragment dirname, List<FileNode> files, List<DirectoryNode> dirs) -> {
diff --git a/src/test/java/com/google/devtools/build/lib/remote/merkletree/PathDirectoryTreeTest.java b/src/test/java/com/google/devtools/build/lib/remote/merkletree/PathDirectoryTreeTest.java
new file mode 100644
index 0000000..38dd3c6
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/remote/merkletree/PathDirectoryTreeTest.java
@@ -0,0 +1,33 @@
+// Copyright 2020 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.remote.merkletree;
+
+import com.google.devtools.build.lib.vfs.Path;
+import com.google.devtools.build.lib.vfs.PathFragment;
+import java.io.IOException;
+import java.util.NavigableMap;
+import java.util.TreeMap;
+
+/** Tests for {@link DirectoryTreeBuilder#fromPaths}. */
+public class PathDirectoryTreeTest extends DirectoryTreeTest {
+
+  @Override
+  protected DirectoryTree build(Path... paths) throws IOException {
+    NavigableMap<PathFragment, Path> inputFiles = new TreeMap<>();
+    for (Path path : paths) {
+      inputFiles.put(path.relativeTo(execRoot), path);
+    }
+    return DirectoryTreeBuilder.fromPaths(inputFiles, digestUtil);
+  }
+}