Implement Hermetic sandbox with support for hardlinks

Adds linux-sandbox flag:
--experimental_use_hermetic_linux_sandbox - Configure linux-sandbox
 to run in a chroot environment to prevent access to files not
 mentioned in the bazel rules unless they can be found via
 explicitly whitelisted directories using --sandbox_add_mount_pair
 create hardlinks instead of symlinks, and fallback to copying.
 In case of writes to input files, the build will be aborted.

Closes #13279.

PiperOrigin-RevId: 395104527
diff --git a/src/main/java/com/google/devtools/build/lib/actions/FileContentsProxy.java b/src/main/java/com/google/devtools/build/lib/actions/FileContentsProxy.java
index f8c9d27..08e1512 100644
--- a/src/main/java/com/google/devtools/build/lib/actions/FileContentsProxy.java
+++ b/src/main/java/com/google/devtools/build/lib/actions/FileContentsProxy.java
@@ -20,27 +20,33 @@
 
 /**
  * In case we can't get a fast digest from the filesystem, we store this metadata as a proxy to the
- * file contents. Currently it is a pair of a relevant timestamp and a "node id". On Linux the
- * former is the ctime and the latter is the inode number. We might want to add the device number in
- * the future.
+ * file contents. Currently it is two timestamps and a "node id". On Linux we use both ctime and
+ * mtime and inode number. We might want to add the device number in the future.
  *
- * <p>For a Linux example of why mtime alone is insufficient, note that 'mv' preserves timestamps.
- * So if files 'a' and 'b' initially have the same timestamp, then we would think 'b' is unchanged
- * after the user executes `mv a b` between two builds.
+ * <p>For a Linux example of why mtime alone is insufficient, note that 'mv' preserves mtime. So if
+ * files 'a' and 'b' initially have the same timestamp, then we would think 'b' is unchanged after
+ * the user executes `mv a b` between two builds.
+ *
+ * <p>On Linux we also need mtime for hardlinking sandbox, since updating the inode reference
+ * counter preserves mtime, but updates ctime. isModified() call can be used to compare two
+ * FileContentsProxys of hardlinked files.
  */
 public final class FileContentsProxy {
   private final long ctime;
+  private final long mtime;
   private final long nodeId;
 
-  private FileContentsProxy(long ctime, long nodeId) {
+  public FileContentsProxy(long ctime, long mtime, long nodeId) {
     this.ctime = ctime;
+    this.mtime = mtime;
     this.nodeId = nodeId;
   }
 
   public static FileContentsProxy create(FileStatus stat) throws IOException {
     // Note: there are file systems that return mtime for this call instead of ctime, such as the
     // WindowsFileSystem.
-    return new FileContentsProxy(stat.getLastChangeTime(), stat.getNodeId());
+    return new FileContentsProxy(
+        stat.getLastChangeTime(), stat.getLastModifiedTime(), stat.getNodeId());
   }
 
   @Override
@@ -54,16 +60,31 @@
     }
 
     FileContentsProxy that = (FileContentsProxy) other;
-    return ctime == that.ctime && nodeId == that.nodeId;
+    return ctime == that.ctime && mtime == that.mtime && nodeId == that.nodeId;
+  }
+
+  /**
+   * Can be used when hardlink reference counter changes should not be considered a file
+   * modification. Is only comparing mtime and not ctime and is therefore not detecting changed
+   * metadata like permission.
+   */
+  @SuppressWarnings("ReferenceEquality")
+  public boolean isModified(FileContentsProxy other) {
+    if (other == this) {
+      return false;
+    }
+    // true if nodeId are different or inode has a new mtime
+    return nodeId != other.nodeId || mtime != other.mtime;
   }
 
   @Override
   public int hashCode() {
-    return Objects.hash(ctime, nodeId);
+    return Objects.hash(ctime, mtime, nodeId);
   }
 
   void addToFingerprint(Fingerprint fp) {
     fp.addLong(ctime);
+    fp.addLong(mtime);
     fp.addLong(nodeId);
   }
 
@@ -73,6 +94,6 @@
   }
 
   public String prettyPrint() {
-    return String.format("ctime of %d and nodeId of %d", ctime, nodeId);
+    return String.format("ctime of %d and mtime of %d and nodeId of %d", ctime, mtime, nodeId);
   }
 }
diff --git a/src/main/java/com/google/devtools/build/lib/sandbox/AbstractSandboxSpawnRunner.java b/src/main/java/com/google/devtools/build/lib/sandbox/AbstractSandboxSpawnRunner.java
index 9f54a94..7272343 100644
--- a/src/main/java/com/google/devtools/build/lib/sandbox/AbstractSandboxSpawnRunner.java
+++ b/src/main/java/com/google/devtools/build/lib/sandbox/AbstractSandboxSpawnRunner.java
@@ -131,6 +131,9 @@
       try (SilentCloseable c = Profiler.instance().profile("subprocess.run")) {
         result = run(originalSpawn, sandbox, context.getTimeout(), outErr);
       }
+      try (SilentCloseable c = Profiler.instance().profile("sandbox.verifyPostCondition")) {
+        verifyPostCondition(originalSpawn, sandbox, context);
+      }
 
       context.lockOutputFiles();
       try (SilentCloseable c = Profiler.instance().profile("sandbox.copyOutputs")) {
@@ -148,6 +151,10 @@
       }
     }
   }
+  /** Override this method if you need to run a post condition after the action has executed */
+  public void verifyPostCondition(
+      Spawn originalSpawn, SandboxedSpawn sandbox, SpawnExecutionContext context)
+      throws IOException, ForbiddenActionInputException {}
 
   private String makeFailureMessage(Spawn originalSpawn, SandboxedSpawn sandbox) {
     if (sandboxOptions.sandboxDebug) {
diff --git a/src/main/java/com/google/devtools/build/lib/sandbox/BUILD b/src/main/java/com/google/devtools/build/lib/sandbox/BUILD
index 0dffde4..8701c24 100644
--- a/src/main/java/com/google/devtools/build/lib/sandbox/BUILD
+++ b/src/main/java/com/google/devtools/build/lib/sandbox/BUILD
@@ -19,6 +19,7 @@
         "//src/main/java/com/google/devtools/build/lib/actions",
         "//src/main/java/com/google/devtools/build/lib/actions:artifacts",
         "//src/main/java/com/google/devtools/build/lib/actions:execution_requirements",
+        "//src/main/java/com/google/devtools/build/lib/actions:file_metadata",
         "//src/main/java/com/google/devtools/build/lib/actions:localhost_capacity",
         "//src/main/java/com/google/devtools/build/lib/analysis:blaze_directories",
         "//src/main/java/com/google/devtools/build/lib/analysis:test/test_configuration",
diff --git a/src/main/java/com/google/devtools/build/lib/sandbox/HardlinkedSandboxedSpawn.java b/src/main/java/com/google/devtools/build/lib/sandbox/HardlinkedSandboxedSpawn.java
new file mode 100644
index 0000000..929f416
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/lib/sandbox/HardlinkedSandboxedSpawn.java
@@ -0,0 +1,101 @@
+// Copyright 2016 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.sandbox;
+
+import com.google.common.flogger.GoogleLogger;
+import com.google.devtools.build.lib.exec.TreeDeleter;
+import com.google.devtools.build.lib.sandbox.SandboxHelpers.SandboxInputs;
+import com.google.devtools.build.lib.sandbox.SandboxHelpers.SandboxOutputs;
+import com.google.devtools.build.lib.vfs.FileSystemUtils;
+import com.google.devtools.build.lib.vfs.Path;
+import com.google.devtools.build.lib.vfs.Symlinks;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import javax.annotation.Nullable;
+
+/**
+ * Creates an execRoot for a Spawn that contains input files as hardlinks to their original
+ * destination.
+ */
+public class HardlinkedSandboxedSpawn extends AbstractContainerizingSandboxedSpawn {
+  private static final GoogleLogger logger = GoogleLogger.forEnclosingClass();
+  private boolean sandboxDebug = false;
+
+  public HardlinkedSandboxedSpawn(
+      Path sandboxPath,
+      Path sandboxExecRoot,
+      List<String> arguments,
+      Map<String, String> environment,
+      SandboxInputs inputs,
+      SandboxOutputs outputs,
+      Set<Path> writableDirs,
+      TreeDeleter treeDeleter,
+      @Nullable Path statisticsPath,
+      boolean sandboxDebug) {
+    super(
+        sandboxPath,
+        sandboxExecRoot,
+        arguments,
+        environment,
+        inputs,
+        outputs,
+        writableDirs,
+        treeDeleter,
+        statisticsPath);
+    this.sandboxDebug = sandboxDebug;
+  }
+
+  @Override
+  protected void copyFile(Path source, Path target) throws IOException {
+    hardLinkRecursive(source, target);
+  }
+
+  /**
+   * Recursively creates hardlinks for all files in {@code source} path, in {@code target} path.
+   * Symlinks are resolved. If files is located on another disk, hardlink will fail and a copy will
+   * be made instead. Throws IllegalArgumentException if source path is a subdirectory of target
+   * path.
+   */
+  private void hardLinkRecursive(Path source, Path target) throws IOException {
+    if (source.isSymbolicLink()) {
+      source = source.resolveSymbolicLinks();
+    }
+
+    if (source.isFile(Symlinks.NOFOLLOW)) {
+      try {
+        source.createHardLink(target);
+      } catch (IOException e) {
+        if (sandboxDebug) {
+          logger.atInfo().log(
+              "File %s could not be hardlinked, file will be copied instead.", source);
+        }
+        FileSystemUtils.copyFile(source, target);
+      }
+    } else if (source.isDirectory()) {
+      if (source.startsWith(target)) {
+        throw new IllegalArgumentException(source + " is a subdirectory of " + target);
+      }
+      target.createDirectory();
+      Collection<Path> entries = source.getDirectoryEntries();
+      for (Path entry : entries) {
+        Path toPath = target.getChild(entry.getBaseName());
+        hardLinkRecursive(entry, toPath);
+      }
+    }
+  }
+}
diff --git a/src/main/java/com/google/devtools/build/lib/sandbox/LinuxSandboxUtil.java b/src/main/java/com/google/devtools/build/lib/sandbox/LinuxSandboxUtil.java
index c320929..551ff44 100644
--- a/src/main/java/com/google/devtools/build/lib/sandbox/LinuxSandboxUtil.java
+++ b/src/main/java/com/google/devtools/build/lib/sandbox/LinuxSandboxUtil.java
@@ -57,7 +57,7 @@
   public static class CommandLineBuilder {
     private final Path linuxSandboxPath;
     private final List<String> commandArguments;
-
+    private Path hermeticSandboxPath;
     private Path workingDirectory;
     private Duration timeout;
     private Duration killDelay;
@@ -79,6 +79,15 @@
       this.commandArguments = commandArguments;
     }
 
+    /**
+     * Sets the sandbox path to chroot to, required for the hermetic linux sandbox to figure out
+     * where the working directory is.
+     */
+    public CommandLineBuilder setHermeticSandboxPath(Path sandboxPath) {
+      this.hermeticSandboxPath = sandboxPath;
+      return this;
+    }
+
     /** Sets the working directory to use, if any. */
     public CommandLineBuilder setWorkingDirectory(Path workingDirectory) {
       this.workingDirectory = workingDirectory;
@@ -221,6 +230,9 @@
       if (statisticsPath != null) {
         commandLineBuilder.add("-S", statisticsPath.getPathString());
       }
+      if (hermeticSandboxPath != null) {
+        commandLineBuilder.add("-h", hermeticSandboxPath.getPathString());
+      }
       if (useFakeHostname) {
         commandLineBuilder.add("-H");
       }
diff --git a/src/main/java/com/google/devtools/build/lib/sandbox/LinuxSandboxedSpawnRunner.java b/src/main/java/com/google/devtools/build/lib/sandbox/LinuxSandboxedSpawnRunner.java
index be23fb0..ffb7a5f 100644
--- a/src/main/java/com/google/devtools/build/lib/sandbox/LinuxSandboxedSpawnRunner.java
+++ b/src/main/java/com/google/devtools/build/lib/sandbox/LinuxSandboxedSpawnRunner.java
@@ -19,12 +19,16 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Maps;
 import com.google.common.io.ByteStreams;
+import com.google.devtools.build.lib.actions.ActionInput;
 import com.google.devtools.build.lib.actions.ExecException;
 import com.google.devtools.build.lib.actions.ExecutionRequirements;
+import com.google.devtools.build.lib.actions.FileArtifactValue;
+import com.google.devtools.build.lib.actions.FileContentsProxy;
 import com.google.devtools.build.lib.actions.ForbiddenActionInputException;
 import com.google.devtools.build.lib.actions.Spawn;
 import com.google.devtools.build.lib.actions.Spawns;
 import com.google.devtools.build.lib.actions.UserExecException;
+import com.google.devtools.build.lib.actions.cache.VirtualActionInput;
 import com.google.devtools.build.lib.analysis.BlazeDirectories;
 import com.google.devtools.build.lib.exec.TreeDeleter;
 import com.google.devtools.build.lib.exec.local.LocalEnvProvider;
@@ -38,6 +42,7 @@
 import com.google.devtools.build.lib.shell.Command;
 import com.google.devtools.build.lib.shell.CommandException;
 import com.google.devtools.build.lib.util.OS;
+import com.google.devtools.build.lib.vfs.FileStatus;
 import com.google.devtools.build.lib.vfs.FileSystem;
 import com.google.devtools.build.lib.vfs.Path;
 import com.google.devtools.build.lib.vfs.PathFragment;
@@ -229,6 +234,19 @@
           sandboxfsMapSymlinkTargets,
           treeDeleter,
           statisticsPath);
+    } else if (getSandboxOptions().useHermetic) {
+      commandLineBuilder.setHermeticSandboxPath(sandboxPath);
+      return new HardlinkedSandboxedSpawn(
+          sandboxPath,
+          sandboxExecRoot,
+          commandLineBuilder.build(),
+          environment,
+          inputs,
+          outputs,
+          writableDirs,
+          treeDeleter,
+          statisticsPath,
+          getSandboxOptions().sandboxDebug);
     } else {
       return new SymlinkedSandboxedSpawn(
           sandboxPath,
@@ -357,6 +375,47 @@
   }
 
   @Override
+  public void verifyPostCondition(
+      Spawn originalSpawn, SandboxedSpawn sandbox, SpawnExecutionContext context)
+      throws IOException, ForbiddenActionInputException {
+    if (getSandboxOptions().useHermetic) {
+      checkForConcurrentModifications(context);
+    }
+  }
+
+  private void checkForConcurrentModifications(SpawnExecutionContext context)
+      throws IOException, ForbiddenActionInputException {
+    for (ActionInput input : (context.getInputMapping(PathFragment.EMPTY_FRAGMENT).values())) {
+      if (input instanceof VirtualActionInput) {
+        continue;
+      }
+
+      FileArtifactValue metadata = context.getMetadataProvider().getMetadata(input);
+      Path path = execRoot.getRelative(input.getExecPath());
+
+      try {
+        if (wasModifiedSinceDigest(metadata.getContentsProxy(), path)) {
+          throw new IOException("input dependency " + path + " was modified during execution.");
+        }
+      } catch (UnsupportedOperationException e) {
+        throw new IOException(
+            "input dependency "
+                + path
+                + " could not be checked for modifications during execution.",
+            e);
+      }
+    }
+  }
+
+  private boolean wasModifiedSinceDigest(FileContentsProxy proxy, Path path) throws IOException {
+    if (proxy == null) {
+      return false;
+    }
+    FileStatus stat = path.statIfFound(Symlinks.FOLLOW);
+    return stat == null || !stat.isFile() || proxy.isModified(FileContentsProxy.create(stat));
+  }
+
+  @Override
   public void cleanupSandboxBase(Path sandboxBase, TreeDeleter treeDeleter) throws IOException {
     // Delete the inaccessible files synchronously, bypassing the treeDeleter. They are only a
     // couple of files that can be deleted fast, and ensuring they are gone at the end of every
diff --git a/src/main/java/com/google/devtools/build/lib/sandbox/SandboxOptions.java b/src/main/java/com/google/devtools/build/lib/sandbox/SandboxOptions.java
index 40e20fb..35b4213 100644
--- a/src/main/java/com/google/devtools/build/lib/sandbox/SandboxOptions.java
+++ b/src/main/java/com/google/devtools/build/lib/sandbox/SandboxOptions.java
@@ -368,6 +368,19 @@
               + " avoid unnecessary setup costs.")
   public boolean reuseSandboxDirectories;
 
+  @Option(
+      name = "experimental_use_hermetic_linux_sandbox",
+      defaultValue = "false",
+      documentationCategory = OptionDocumentationCategory.EXECUTION_STRATEGY,
+      effectTags = {OptionEffectTag.EXECUTION},
+      help =
+          "If set to true, do not mount root, only mount whats provided with "
+              + "sandbox_add_mount_pair. Input files will be hardlinked to the sandbox instead of "
+              + "symlinked to from the sandbox. "
+              + "If action input files are located on a filesystem different from the sandbox, "
+              + "then the input files will be copied instead.")
+  public boolean useHermetic;
+
   /** Converter for the number of threads used for asynchronous tree deletion. */
   public static final class AsyncTreeDeletesConverter extends ResourceConverter {
     public AsyncTreeDeletesConverter() {
diff --git a/src/main/tools/linux-sandbox-options.cc b/src/main/tools/linux-sandbox-options.cc
index 088f794..a9a7ac5 100644
--- a/src/main/tools/linux-sandbox-options.cc
+++ b/src/main/tools/linux-sandbox-options.cc
@@ -73,6 +73,9 @@
           "  -R  if set, make the uid/gid be root\n"
           "  -U  if set, make the uid/gid be nobody\n"
           "  -D  if set, debug info will be printed\n"
+          "  -h <sandbox-dir>  if set, chroot to sandbox-dir and only "
+          " mount whats been specified with -M/-m for improved hermeticity. "
+          " The working-dir should be a folder inside the sandbox-dir\n"
           "  @FILE  read newline-separated arguments from FILE\n"
           "  --  command to run inside sandbox, followed by arguments\n");
   exit(EXIT_FAILURE);
@@ -94,7 +97,7 @@
   bool source_specified = false;
 
   while ((c = getopt(args->size(), args->data(),
-                     ":W:T:t:il:L:w:e:M:m:S:HNRUD")) != -1) {
+                     ":W:T:t:il:L:w:e:M:m:S:h:HNRUD")) != -1) {
     if (c != 'M' && c != 'm') source_specified = false;
     switch (c) {
       case 'W':
@@ -170,6 +173,26 @@
                 "Cannot write stats to more than one destination.");
         }
         break;
+      case 'h':
+        opt.hermetic = true;
+        if (opt.sandbox_root.empty()) {
+          std::string sandbox_root(optarg);
+          // Make sure that the sandbox_root path has no trailing slash.
+          if (sandbox_root.back() == '/') {
+            ValidateIsAbsolutePath(optarg, args->front(), static_cast<char>(c));
+            opt.sandbox_root.assign(sandbox_root, 0, sandbox_root.length() - 1);
+            if (opt.sandbox_root.back() == '/') {
+              Usage(args->front(),
+                    "Sandbox root path should not have trailing slashes");
+            }
+          } else {
+            opt.sandbox_root.assign(sandbox_root);
+          }
+        } else {
+          Usage(args->front(),
+                "Multiple sandbox roots (-s) specified, expected one.");
+        }
+        break;
       case 'H':
         opt.fake_hostname = true;
         break;
@@ -204,6 +227,13 @@
     }
   }
 
+  if (!opt.working_dir.empty() && !opt.sandbox_root.empty() &&
+      opt.working_dir.find(opt.sandbox_root) == std::string::npos) {
+    Usage(args->front(),
+          "working-dir %s (-W) should be a "
+          "subdirectory of sandbox-dir %s (-h)",
+          opt.working_dir.c_str(), opt.sandbox_root.c_str());
+  }
   if (optind < static_cast<int>(args->size())) {
     if (opt.args.empty()) {
       opt.args.assign(args->begin() + optind, args->end());
diff --git a/src/main/tools/linux-sandbox-options.h b/src/main/tools/linux-sandbox-options.h
index d3b77d4..2843e9f 100644
--- a/src/main/tools/linux-sandbox-options.h
+++ b/src/main/tools/linux-sandbox-options.h
@@ -54,6 +54,10 @@
   bool fake_username;
   // Print debugging messages (-D)
   bool debug;
+  // Improved hermetic build using whitelisting strategy (-h)
+  bool hermetic;
+  // The sandbox root directory (-s)
+  std::string sandbox_root;
   // Command to run (--)
   std::vector<char *> args;
 };
diff --git a/src/main/tools/linux-sandbox-pid1.cc b/src/main/tools/linux-sandbox-pid1.cc
index 33c3543c..b6f4f3d 100644
--- a/src/main/tools/linux-sandbox-pid1.cc
+++ b/src/main/tools/linux-sandbox-pid1.cc
@@ -67,8 +67,100 @@
 #include "src/main/tools/logging.h"
 #include "src/main/tools/process-tools.h"
 
+static void WriteFile(const std::string &filename, const char *fmt, ...) {
+  FILE *stream = fopen(filename.c_str(), "w");
+  if (stream == nullptr) {
+    DIE("fopen(%s)", filename.c_str());
+  }
+
+  va_list ap;
+  va_start(ap, fmt);
+  int r = vfprintf(stream, fmt, ap);
+  va_end(ap);
+
+  if (r < 0) {
+    DIE("vfprintf");
+  }
+
+  if (fclose(stream) != 0) {
+    DIE("fclose(%s)", filename.c_str());
+  }
+}
+
 static int global_child_pid;
 
+// Helper methods
+static void CreateFile(const char *path) {
+  int handle = open(path, O_CREAT | O_WRONLY | O_EXCL, 0666);
+  if (handle < 0) {
+    DIE("open");
+  }
+  if (close(handle) < 0) {
+    DIE("close");
+  }
+}
+
+// Creates an empty file at 'path' by hard linking it from a known empty file.
+// This is over two times faster than creating empty files via open() on
+// certain filesystems (e.g. XFS).
+static void LinkFile(const char *path) {
+  if (link("tmp/empty_file", path) < 0) {
+    DIE("link %s", path);
+  }
+}
+
+// Recursively creates the file or directory specified in "path" and its parent
+// directories.
+// Return -1 on failure and sets errno to:
+//    EINVAL   path is null
+//    ENOTDIR  path exists and is not a directory
+//    EEXIST   path exists and is a directory
+//    ENOENT   stat call with the path failed
+static int CreateTarget(const char *path, bool is_directory) {
+  if (path == NULL) {
+    errno = EINVAL;
+    return -1;
+  }
+
+  struct stat sb;
+  // If the path already exists...
+
+  if (stat(path, &sb) == 0) {
+    if (is_directory && S_ISDIR(sb.st_mode)) {
+      // and it's a directory and supposed to be a directory, we're done here.
+      return 0;
+    } else if (!is_directory && S_ISREG(sb.st_mode)) {
+      // and it's a regular file and supposed to be one, we're done here.
+      return 0;
+    } else {
+      // otherwise something is really wrong.
+      errno = is_directory ? ENOTDIR : EEXIST;
+      return -1;
+    }
+  } else {
+    // If stat failed because of any error other than "the path does not exist",
+    // this is an error.
+    if (errno != ENOENT) {
+      return -1;
+    }
+  }
+
+  // Create the parent directory.
+  if (CreateTarget(dirname(strdupa(path)), true) < 0) {
+    DIE("CreateTarget %s", dirname(strdupa(path)));
+  }
+
+  if (is_directory) {
+    if (mkdir(path, 0755) < 0) {
+      DIE("mkdir");
+    }
+  } else {
+    LinkFile(path);
+  }
+
+  return 0;
+}
+
 static void SetupSelfDestruction(int *sync_pipe) {
   // We could also poll() on the pipe fd to find out when the parent goes away,
   // and rely on SIGCHLD interrupting that otherwise. That might require us to
@@ -107,26 +199,6 @@
   }
 }
 
-static void WriteFile(const std::string &filename, const char *fmt, ...) {
-  FILE *stream = fopen(filename.c_str(), "w");
-  if (stream == nullptr) {
-    DIE("fopen(%s)", filename.c_str());
-  }
-
-  va_list ap;
-  va_start(ap, fmt);
-  int r = vfprintf(stream, fmt, ap);
-  va_end(ap);
-
-  if (r < 0) {
-    DIE("vfprintf");
-  }
-
-  if (fclose(stream) != 0) {
-    DIE("fclose(%s)", filename.c_str());
-  }
-}
-
 static void SetupUserNamespace() {
   // Disable needs for CAP_SETGID.
   struct stat sb;
@@ -159,7 +231,6 @@
     inner_uid = global_outer_uid;
     inner_gid = global_outer_gid;
   }
-
   WriteFile("/proc/self/uid_map", "%d %d 1\n", inner_uid, global_outer_uid);
   WriteFile("/proc/self/gid_map", "%d %d 1\n", inner_gid, global_outer_gid);
 }
@@ -361,9 +432,14 @@
   }
 }
 
-static void EnterSandbox() {
-  if (chdir(opt.working_dir.c_str()) < 0) {
-    DIE("chdir(%s)", opt.working_dir.c_str());
+static void EnterWorkingDirectory() {
+  std::string path = opt.working_dir;
+  if (opt.hermetic) {
+    path = path.substr(opt.sandbox_root.size() + 1);
+  }
+
+  if (chdir(path.c_str()) < 0) {
+    DIE("chdir(%s)", path.c_str());
   }
 }
 
@@ -385,7 +461,7 @@
 
     // Try to assign our terminal to the child process.
     if (tcsetpgrp(STDIN_FILENO, getpgrp()) < 0 && errno != ENOTTY) {
-      DIE("tcsetpgrp")
+      DIE("tcsetpgrp");
     }
 
     // Unblock all signals, restore default handlers.
@@ -442,6 +518,125 @@
   }
 }
 
+static void MountSandboxAndGoThere() {
+  if (mount(opt.sandbox_root.c_str(), opt.sandbox_root.c_str(), nullptr,
+            MS_BIND | MS_NOSUID, nullptr) < 0) {
+    DIE("mount");
+  }
+  if (chdir(opt.sandbox_root.c_str()) < 0) {
+    DIE("chdir(%s)", opt.sandbox_root.c_str());
+  }
+}
+
+static void CreateEmptyFile() {
+  // This is used as the base for bind mounting.
+  if (CreateTarget("tmp", true) < 0) {
+    DIE("CreateTarget tmp")
+  }
+  CreateFile("tmp/empty_file");
+}
+
+static void MountDev() {
+  if (CreateTarget("dev", true) < 0) {
+    DIE("CreateTarget /dev");
+  }
+  const char *devs[] = {"/dev/null", "/dev/random", "/dev/urandom", "/dev/zero",
+                        NULL};
+  for (int i = 0; devs[i] != NULL; i++) {
+    LinkFile(devs[i] + 1);
+    if (mount(devs[i], devs[i] + 1, NULL, MS_BIND, NULL) < 0) {
+      DIE("mount");
+    }
+  }
+  if (symlink("/proc/self/fd", "dev/fd") < 0) {
+    DIE("symlink");
+  }
+}
+
+static void MountAllMounts() {
+  for (const std::string &tmpfs_dir : opt.tmpfs_dirs) {
+    PRINT_DEBUG("tmpfs: %s", tmpfs_dir.c_str());
+    if (mount("tmpfs", tmpfs_dir.c_str(), "tmpfs",
+              MS_NOSUID | MS_NODEV | MS_NOATIME, nullptr) < 0) {
+      DIE("mount(tmpfs, %s, tmpfs, MS_NOSUID | MS_NODEV | MS_NOATIME, nullptr)",
+          tmpfs_dir.c_str());
+    }
+  }
+
+  // Make sure that our working directory is a mount point. The easiest way to
+  // do this is by bind-mounting it upon itself.
+  if (mount(opt.working_dir.c_str(), opt.working_dir.c_str(), nullptr, MS_BIND,
+            nullptr) < 0) {
+    DIE("mount(%s, %s, nullptr, MS_BIND, nullptr)", opt.working_dir.c_str(),
+        opt.working_dir.c_str());
+  }
+  for (int i = 0; i < (signed)opt.bind_mount_sources.size(); i++) {
+    if (opt.debug) {
+      if (strcmp(opt.bind_mount_sources[i].c_str(),
+                 opt.bind_mount_targets[i].c_str()) == 0) {
+        // The file is mounted to the same path inside the sandbox, as outside
+        // (e.g. /home/user -> <sandbox>/home/user), so we'll just show a
+        // simplified version of the mount command.
+        PRINT_DEBUG("mount: %s\n", opt.bind_mount_sources[i].c_str());
+      } else {
+        // The file is mounted to a custom location inside the sandbox.
+        // Create a user-friendly string for the sandboxed path and show it.
+        const std::string user_friendly_mount_target("<sandbox>" +
+                                                     opt.bind_mount_targets[i]);
+        PRINT_DEBUG("mount: %s -> %s\n", opt.bind_mount_sources[i].c_str(),
+                    user_friendly_mount_target.c_str());
+      }
+    }
+    const std::string full_sandbox_path(opt.sandbox_root +
+                                        opt.bind_mount_targets[i]);
+
+    struct stat sb;
+    if (stat(opt.bind_mount_sources[i].c_str(), &sb) < 0) {
+      DIE("stat");
+    }
+    bool IsDirectory = S_ISDIR(sb.st_mode);
+    if (CreateTarget(full_sandbox_path.c_str(), IsDirectory) < 0) {
+      DIE("CreateTarget %s", full_sandbox_path.c_str());
+    }
+    int result =
+        mount(opt.bind_mount_sources[i].c_str(), full_sandbox_path.c_str(),
+              NULL, MS_REC | MS_BIND | MS_RDONLY, NULL);
+    if (result != 0) {
+      DIE("mount");
+    }
+  }
+  for (const std::string &writable_file : opt.writable_files) {
+    PRINT_DEBUG("writable: %s", writable_file.c_str());
+    if (mount(writable_file.c_str(), writable_file.c_str(), nullptr,
+              MS_BIND | MS_REC, nullptr) < 0) {
+      DIE("mount(%s, %s, nullptr, MS_BIND | MS_REC, nullptr)",
+          writable_file.c_str(), writable_file.c_str());
+    }
+  }
+}
+
+static void ChangeRoot() {
+  // move the real root to old_root, then detach it
+  char old_root[16] = "old-root-XXXXXX";
+  if (mkdtemp(old_root) == NULL) {
+    perror("mkdtemp");
+    DIE("mkdtemp returned NULL\n");
+  }
+  // pivot_root has no wrapper in libc, so we need syscall()
+  if (syscall(SYS_pivot_root, ".", old_root) < 0) {
+    DIE("syscall");
+  }
+  if (chroot(".") < 0) {
+    DIE("chroot");
+  }
+  if (umount2(old_root, MNT_DETACH) < 0) {
+    DIE("umount2");
+  }
+  if (rmdir(old_root) < 0) {
+    DIE("rmdir");
+  }
+}
+
 int Pid1Main(void *sync_pipe_param) {
   PRINT_DEBUG("Pid1Main started");
 
@@ -460,11 +655,21 @@
   if (opt.fake_hostname) {
     SetupUtsNamespace();
   }
-  MountFilesystems();
-  MakeFilesystemMostlyReadOnly();
-  MountProc();
+
+  if (opt.hermetic) {
+    MountSandboxAndGoThere();
+    CreateEmptyFile();
+    MountDev();
+    MountProc();
+    MountAllMounts();
+    ChangeRoot();
+  } else {
+    MountFilesystems();
+    MakeFilesystemMostlyReadOnly();
+    MountProc();
+  }
   SetupNetworking();
-  EnterSandbox();
+  EnterWorkingDirectory();
 
   // Ignore terminal signals; we hand off the terminal to the child in
   // SpawnChild below.
diff --git a/src/test/java/com/google/devtools/build/lib/actions/FileContentsProxyTest.java b/src/test/java/com/google/devtools/build/lib/actions/FileContentsProxyTest.java
index a405fe7..f00179f 100644
--- a/src/test/java/com/google/devtools/build/lib/actions/FileContentsProxyTest.java
+++ b/src/test/java/com/google/devtools/build/lib/actions/FileContentsProxyTest.java
@@ -42,8 +42,8 @@
 
     InjectedStat(long ctime, long nodeId) {
       this.ctime = ctime;
+      this.mtime = ctime;
       this.nodeId = nodeId;
-      this.mtime = 0;
       this.size = 0;
     }
 
@@ -108,6 +108,6 @@
     Fingerprint fingerprint = new Fingerprint();
     p1.addToFingerprint(fingerprint);
     assertThat(fingerprint.digestAndReset())
-        .isEqualTo(new Fingerprint().addLong(2L).addLong(4L).digestAndReset());
+        .isEqualTo(new Fingerprint().addLong(2L).addLong(1L).addLong(4L).digestAndReset());
   }
 }
diff --git a/src/test/java/com/google/devtools/build/lib/skyframe/FileFunctionTest.java b/src/test/java/com/google/devtools/build/lib/skyframe/FileFunctionTest.java
index 83fac54..21047ad 100644
--- a/src/test/java/com/google/devtools/build/lib/skyframe/FileFunctionTest.java
+++ b/src/test/java/com/google/devtools/build/lib/skyframe/FileFunctionTest.java
@@ -576,7 +576,7 @@
     assertThat(value.getDigest()).isNull();
 
     p.setLastModifiedTime(10L);
-    assertThat(valueForPath(p)).isEqualTo(value);
+    assertThat(valueForPath(p)).isNotEqualTo(value);
 
     p.setLastModifiedTime(0L);
     assertThat(valueForPath(p)).isEqualTo(value);
diff --git a/src/test/shell/bazel/BUILD b/src/test/shell/bazel/BUILD
index 5fd3793..63f7b63 100644
--- a/src/test/shell/bazel/BUILD
+++ b/src/test/shell/bazel/BUILD
@@ -953,6 +953,20 @@
 )
 
 sh_test(
+    name = "bazel_hermetic_sandboxing_test",
+    size = "small",
+    srcs = ["bazel_hermetic_sandboxing_test.sh"],
+    data = [
+        ":test-deps",
+        "//src/test/shell:sandboxing_test_utils.sh",
+    ],
+    tags = [
+        "no-sandbox",
+        "no_windows",
+    ],
+)
+
+sh_test(
     name = "bazel_sandboxing_cpp_test",
     srcs = ["bazel_sandboxing_cpp_test.sh"],
     data = [
diff --git a/src/test/shell/bazel/bazel_hermetic_sandboxing_test.sh b/src/test/shell/bazel/bazel_hermetic_sandboxing_test.sh
new file mode 100755
index 0000000..a67f4be
--- /dev/null
+++ b/src/test/shell/bazel/bazel_hermetic_sandboxing_test.sh
@@ -0,0 +1,183 @@
+#!/bin/bash
+#
+# Copyright 2015 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.
+#
+# Test hermetic Linux sandbox
+#
+
+
+# Load test environment
+# Load the test setup defined in the parent directory
+CURRENT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source "${CURRENT_DIR}/../integration_test_setup.sh" \
+  || { echo "integration_test_setup.sh not found!" >&2; exit 1; }
+source ${CURRENT_DIR}/../sandboxing_test_utils.sh \
+  || { echo "sandboxing_test_utils.sh not found!" >&2; exit 1; }
+
+cat >>$TEST_TMPDIR/bazelrc <<'EOF'
+# Testing the sandboxed strategy requires using the sandboxed strategy. While it is the default,
+# we want to make sure that this explicitly fails when the strategy is not available on the system
+# running the test.
+# The hermetic sandbox requires the Linux sandbox.
+build --spawn_strategy=sandboxed
+build --experimental_use_hermetic_linux_sandbox
+build --sandbox_fake_username
+EOF
+
+# For the test to work we need to bind mount a couple of folders to
+# get access to bash, ls, python etc. Depending on linux distribution
+# these folders may vary. Mount all folders in the root directory '/'
+# except the project directory, the directory containing the bazel
+# workspace under test.
+project_folder=`pwd | cut -d"/" -f 2`
+for folder in /*/
+do
+  if [ -d "$folder" ] && [ "$folder" != "/$project_folder/" ]
+  then
+    if [[ -L $folder ]]
+    then
+      # Get resolved link
+      linked_folder=`readlink -f $folder`
+      echo "build --sandbox_add_mount_pair=/$linked_folder:$folder" >> $TEST_TMPDIR/bazelrc
+    else
+      echo "build --sandbox_add_mount_pair=$folder" >> $TEST_TMPDIR/bazelrc
+    fi
+  fi
+done
+
+function set_up {
+  export BAZEL_GENFILES_DIR=$(bazel info bazel-genfiles 2>/dev/null)
+  export BAZEL_BIN_DIR=$(bazel info bazel-bin 2>/dev/null)
+
+  sed -i.bak '/sandbox_tmpfs_path/d' $TEST_TMPDIR/bazelrc
+
+  mkdir -p examples/hermetic
+
+  cat << 'EOF' > examples/hermetic/unknown_file.txt
+text inside this file
+EOF
+
+  ABSOLUTE_PATH=$CURRENT_DIR/workspace/examples/hermetic/unknown_file.txt
+
+  # In this case the ABSOLUTE_PATH will be expanded
+  # and the absolute path will be written to script_absolute_path.sh
+  cat << EOF > examples/hermetic/script_absolute_path.sh
+#! /bin/sh
+ls ${ABSOLUTE_PATH}
+EOF
+
+  chmod 777 examples/hermetic/script_absolute_path.sh
+
+  cat << 'EOF' > examples/hermetic/script_symbolic_link.sh
+#! /bin/sh
+OUTSIDE_SANDBOX_DIR=$(dirname $(realpath $0))
+cat $OUTSIDE_SANDBOX_DIR/unknown_file.txt
+EOF
+
+  chmod 777 examples/hermetic/script_symbolic_link.sh
+
+  touch examples/hermetic/import_module.py
+
+  cat << 'EOF' > examples/hermetic/py_module_test.py
+import import_module
+EOF
+
+  cat << 'EOF' > examples/hermetic/BUILD
+genrule(
+  name = "absolute_path",
+  srcs = ["script_absolute_path.sh"], # unknown_file.txt not referenced.
+  outs = [ "absolute_path.txt" ],
+  cmd = "./$(location :script_absolute_path.sh) > $@",
+)
+
+genrule(
+  name = "symbolic_link",
+  srcs = ["script_symbolic_link.sh"], # unknown_file.txt not referenced.
+  outs = ["symbolic_link.txt"],
+  cmd = "./$(location :script_symbolic_link.sh) > $@",
+)
+
+py_test(
+  name = "py_module_test",
+  srcs = ["py_module_test.py"],  # import_module.py not referenced.
+  size = "small",
+)
+
+genrule(
+  name = "input_file",
+  outs = ["input_file.txt"],
+  cmd = "echo original text input > $@",
+)
+
+genrule(
+  name = "write_input_test",
+  srcs = [":input_file"],
+  outs = ["status.txt"],
+  cmd = "(chmod 777 $(location :input_file) && \
+         (echo overwrite text > $(location :input_file)) && \
+         (echo success > $@)) || (echo fail > $@)",
+)
+EOF
+}
+
+# Test that the build can't escape the sandbox via absolute path.
+function test_absolute_path() {
+  bazel build examples/hermetic:absolute_path &> $TEST_log \
+    && fail "Fail due to non hermetic sandbox: examples/hermetic:absolute_path" || true
+  expect_log "ls:.* '\?.*/examples/hermetic/unknown_file.txt'\?: No such file or directory"
+}
+
+# Test that the build can't escape the sandbox by resolving symbolic link.
+function test_symbolic_link() {
+  [ "$PLATFORM" != "darwin" ] || return 0
+
+  bazel build examples/hermetic:symbolic_link &> $TEST_log \
+    && fail "Fail due to non hermetic sandbox: examples/hermetic:symbolic_link" || true
+  expect_log "cat: \/execroot\/main\/examples\/hermetic\/unknown_file.txt: No such file or directory"
+}
+
+# Test that the sandbox discover if the bazel python rule miss dependencies.
+function test_missing_python_deps() {
+  [ "$PLATFORM" != "darwin" ] || return 0
+
+  bazel test examples/hermetic:py_module_test --test_output=all &> $TEST_TMPDIR/log \
+    && fail "Fail due to non hermetic sandbox: examples/hermetic:py_module_test" || true
+
+  expect_log "No module named '\?import_module'\?"
+}
+
+# Test that the intermediate corrupt input file gets re:evaluated
+function test_writing_input_file() {
+  [ "$PLATFORM" != "darwin" ] || return 0
+  # Write an input file, this should cause the hermetic sandbox to fail with an exception
+  bazel build examples/hermetic:write_input_test &> $TEST_log  \
+    && fail "Fail due to non hermetic sandbox: examples/hermetic:write_input_test" || true
+  expect_log "input dependency .*examples/hermetic/input_file.txt was modified during execution."
+  cat "${BAZEL_GENFILES_DIR}/examples/hermetic/input_file.txt" &> $TEST_log
+  expect_log "overwrite text"
+
+  # Build the input file again, this should not use the cache, but instead re:evaluate the file
+  bazel build examples/hermetic:input_file &> $TEST_log \
+    || fail "Fail due to non hermetic sandbox: examples/hermetic:input_file"
+  [ -f "${BAZEL_GENFILES_DIR}/examples/hermetic/input_file.txt" ] \
+    || fail "Genrule did not produce output: examples/hermetic:input_file"
+  cat "${BAZEL_GENFILES_DIR}/examples/hermetic/input_file.txt" &> $TEST_log
+  expect_log "original text input"
+}
+
+# The test shouldn't fail if the environment doesn't support running it.
+check_sandbox_allowed || exit 0
+
+run_suite "hermetic_sandbox"