Support subresource integrity format for `download()` checksums.

Closes #4881
Closes #7208.

PiperOrigin-RevId: 258577605
diff --git a/src/main/java/com/google/devtools/build/lib/bazel/debug/WorkspaceRuleEvent.java b/src/main/java/com/google/devtools/build/lib/bazel/debug/WorkspaceRuleEvent.java
index 8518488..5c17598 100644
--- a/src/main/java/com/google/devtools/build/lib/bazel/debug/WorkspaceRuleEvent.java
+++ b/src/main/java/com/google/devtools/build/lib/bazel/debug/WorkspaceRuleEvent.java
@@ -84,6 +84,7 @@
       List<URL> urls,
       String output,
       String sha256,
+      String integrity,
       Boolean executable,
       String ruleLabel,
       Location location) {
@@ -91,6 +92,7 @@
         WorkspaceLogProtos.DownloadEvent.newBuilder()
             .setOutput(output)
             .setSha256(sha256)
+            .setIntegrity(integrity)
             .setExecutable(executable);
     for (URL u : urls) {
       e.addUrl(u.toString());
@@ -135,6 +137,7 @@
       List<URL> urls,
       String output,
       String sha256,
+      String integrity,
       String type,
       String stripPrefix,
       String ruleLabel,
@@ -143,6 +146,7 @@
         WorkspaceLogProtos.DownloadAndExtractEvent.newBuilder()
             .setOutput(output)
             .setSha256(sha256)
+            .setIntegrity(integrity)
             .setType(type)
             .setStripPrefix(stripPrefix);
     for (URL u : urls) {
diff --git a/src/main/java/com/google/devtools/build/lib/bazel/debug/workspace_log.proto b/src/main/java/com/google/devtools/build/lib/bazel/debug/workspace_log.proto
index a90f7d9..d6ce5b8 100644
--- a/src/main/java/com/google/devtools/build/lib/bazel/debug/workspace_log.proto
+++ b/src/main/java/com/google/devtools/build/lib/bazel/debug/workspace_log.proto
@@ -44,10 +44,12 @@
   repeated string url = 1;
   // Output file
   string output = 2;
-  // sha256, if speficied
+  // sha256, if specified
   string sha256 = 3;
   // whether to make the resulting file executable
   bool executable = 4;
+  // checksum in Subresource Integrity format, if specified
+  string integrity = 5;
 }
 
 message ExtractEvent {
@@ -70,6 +72,8 @@
   string type = 4;
   // A directory prefix to strip from extracted files.
   string strip_prefix = 5;
+  // checksum in Subresource Integrity format, if specified
+  string integrity = 6;
 }
 
 // Information on "file" event in repository_ctx.
diff --git a/src/main/java/com/google/devtools/build/lib/bazel/repository/cache/RepositoryCache.java b/src/main/java/com/google/devtools/build/lib/bazel/repository/cache/RepositoryCache.java
index 54de121..5b16cd8 100644
--- a/src/main/java/com/google/devtools/build/lib/bazel/repository/cache/RepositoryCache.java
+++ b/src/main/java/com/google/devtools/build/lib/bazel/repository/cache/RepositoryCache.java
@@ -37,7 +37,9 @@
   /** The types of cache keys used. */
   public enum KeyType {
     SHA1("SHA-1", "\\p{XDigit}{40}", "sha1", Hashing.sha1()),
-    SHA256("SHA-256", "\\p{XDigit}{64}", "sha256", Hashing.sha256());
+    SHA256("SHA-256", "\\p{XDigit}{64}", "sha256", Hashing.sha256()),
+    SHA384("SHA-384", "\\p{XDigit}{96}", "sha384", Hashing.sha384()),
+    SHA512("SHA-512", "\\p{XDigit}{128}", "sha512", Hashing.sha512());
 
     private final String stringRepr;
     private final String regexp;
@@ -64,6 +66,10 @@
       return hashFunction.newHasher();
     }
 
+    public String getHashName() {
+      return hashName;
+    }
+
     @Override
     public String toString() {
       return stringRepr;
diff --git a/src/main/java/com/google/devtools/build/lib/bazel/repository/downloader/Checksum.java b/src/main/java/com/google/devtools/build/lib/bazel/repository/downloader/Checksum.java
index 07ca16d..0c992bf 100644
--- a/src/main/java/com/google/devtools/build/lib/bazel/repository/downloader/Checksum.java
+++ b/src/main/java/com/google/devtools/build/lib/bazel/repository/downloader/Checksum.java
@@ -16,6 +16,7 @@
 
 import com.google.common.hash.HashCode;
 import com.google.devtools.build.lib.bazel.repository.cache.RepositoryCache.KeyType;
+import java.util.Base64;
 
 /** The content checksum for an HTTP download, which knows its own type. */
 public class Checksum {
@@ -35,6 +36,49 @@
     return new Checksum(keyType, HashCode.fromString(hash));
   }
 
+  /** Constructs a new Checksum from a hash in Subresource Integrity format. */
+  public static Checksum fromSubresourceIntegrity(String integrity) {
+    Base64.Decoder decoder = Base64.getDecoder();
+    KeyType keyType = null;
+    byte[] hash = null;
+    int expectedLength = 0;
+
+    if (integrity.startsWith("sha256-")) {
+      keyType = KeyType.SHA256;
+      expectedLength = 32;
+      hash = decoder.decode(integrity.substring(7));
+    }
+    if (integrity.startsWith("sha384-")) {
+      keyType = KeyType.SHA384;
+      expectedLength = 48;
+      hash = decoder.decode(integrity.substring(7));
+    }
+    if (integrity.startsWith("sha512-")) {
+      keyType = KeyType.SHA512;
+      expectedLength = 64;
+      hash = decoder.decode(integrity.substring(7));
+    }
+
+    if (keyType == null) {
+      throw new IllegalArgumentException(
+          "Unsupported checksum algorithm: '"
+              + integrity
+              + "' (expected SHA-256, SHA-384, or SHA-512)");
+    }
+
+    if (hash.length != expectedLength) {
+      throw new IllegalArgumentException(
+          "Invalid " + keyType + " SRI checksum '" + integrity + "'");
+    }
+
+    return Checksum.fromString(keyType, HashCode.fromBytes(hash).toString());
+  }
+
+  public String toSubresourceIntegrity() {
+    String encoded = Base64.getEncoder().encodeToString(hashCode.asBytes());
+    return keyType.getHashName() + "-" + encoded;
+  }
+
   @Override
   public String toString() {
     return hashCode.toString();
diff --git a/src/main/java/com/google/devtools/build/lib/bazel/repository/skylark/SkylarkRepositoryContext.java b/src/main/java/com/google/devtools/build/lib/bazel/repository/skylark/SkylarkRepositoryContext.java
index 2ed70f6..c9b7d83 100644
--- a/src/main/java/com/google/devtools/build/lib/bazel/repository/skylark/SkylarkRepositoryContext.java
+++ b/src/main/java/com/google/devtools/build/lib/bazel/repository/skylark/SkylarkRepositoryContext.java
@@ -525,7 +525,7 @@
     return null;
   }
 
-  private void warnAboutSha256Error(List<URL> urls, String sha256) {
+  private void warnAboutChecksumError(List<URL> urls, String errorMessage) {
     // Inform the user immediately, even though the file will still be downloaded.
     // This cannot be done by a regular error event, as all regular events are recorded
     // and only shown once the execution of the repository rule is finished.
@@ -534,7 +534,7 @@
     if (urls.size() > 0) {
       url = urls.get(0).toString();
     }
-    reportProgress("Will fail after download of " + url + ". Invalid SHA256 '" + sha256 + "'");
+    reportProgress("Will fail after download of " + url + ". " + errorMessage);
   }
 
   @Override
@@ -546,27 +546,32 @@
       Boolean allowFail,
       String canonicalId,
       SkylarkDict<String, SkylarkDict<Object, Object>> auth,
+      String integrity,
       Location location)
       throws RepositoryFunctionException, EvalException, InterruptedException {
     Map<URI, Map<String, String>> authHeaders = getAuthHeaders(auth);
 
     List<URL> urls =
         getUrls(url, /* ensureNonEmpty= */ !allowFail, env, !Strings.isNullOrEmpty(sha256));
-    RepositoryFunctionException sha256Validation = validateSha256(sha256, location);
-    if (sha256Validation != null) {
-      warnAboutSha256Error(urls, sha256);
-      sha256 = "";
-    }
     Optional<Checksum> checksum;
-    if (sha256.isEmpty()) {
-      checksum = Optional.absent();
-    } else {
-      checksum = Optional.of(Checksum.fromString(KeyType.SHA256, sha256));
+    RepositoryFunctionException checksumValidation = null;
+    try {
+      checksum = validateChecksum(sha256, integrity, urls, location);
+    } catch (RepositoryFunctionException e) {
+      checksum = Optional.<Checksum>absent();
+      checksumValidation = e;
     }
+
     SkylarkPath outputPath = getPath("download()", output);
     WorkspaceRuleEvent w =
         WorkspaceRuleEvent.newDownloadEvent(
-            urls, output.toString(), sha256, executable, rule.getLabel().toString(), location);
+            urls,
+            output.toString(),
+            sha256,
+            integrity,
+            executable,
+            rule.getLabel().toString(),
+            location);
     env.getListener().post(w);
     Path downloadedPath;
     try {
@@ -597,20 +602,11 @@
         throw new RepositoryFunctionException(e, Transience.TRANSIENT);
       }
     }
-    if (sha256Validation != null) {
-      throw sha256Validation;
+    if (checksumValidation != null) {
+      throw checksumValidation;
     }
-    String finalSha256;
-    try {
-      finalSha256 = calculateSha256(sha256, downloadedPath);
-    } catch (IOException e) {
-      throw new RepositoryFunctionException(
-          new IOException(
-              "Couldn't hash downloaded file (" + downloadedPath.getPathString() + ")", e),
-          Transience.PERSISTENT);
-    }
-    SkylarkDict<String, Object> dict = SkylarkDict.of(null, "sha256", finalSha256, "success", true);
-    return StructProvider.STRUCT.createStruct(dict, null);
+
+    return calculateDownloadResult(checksum, downloadedPath);
   }
 
   @Override
@@ -658,22 +654,20 @@
       Boolean allowFail,
       String canonicalId,
       SkylarkDict<String, SkylarkDict<Object, Object>> auth,
+      String integrity,
       Location location)
       throws RepositoryFunctionException, InterruptedException, EvalException {
     Map<URI, Map<String, String>> authHeaders = getAuthHeaders(auth);
 
     List<URL> urls =
         getUrls(url, /* ensureNonEmpty= */ !allowFail, env, !Strings.isNullOrEmpty(sha256));
-    RepositoryFunctionException sha256Validation = validateSha256(sha256, location);
-    if (sha256Validation != null) {
-      warnAboutSha256Error(urls, sha256);
-      sha256 = "";
-    }
     Optional<Checksum> checksum;
-    if (sha256.isEmpty()) {
-      checksum = Optional.absent();
-    } else {
-      checksum = Optional.of(Checksum.fromString(KeyType.SHA256, sha256));
+    RepositoryFunctionException checksumValidation = null;
+    try {
+      checksum = validateChecksum(sha256, integrity, urls, location);
+    } catch (RepositoryFunctionException e) {
+      checksum = Optional.<Checksum>absent();
+      checksumValidation = e;
     }
 
     WorkspaceRuleEvent w =
@@ -681,6 +675,7 @@
             urls,
             output.toString(),
             sha256,
+            integrity,
             type,
             stripPrefix,
             rule.getLabel().toString(),
@@ -717,8 +712,8 @@
         throw new RepositoryFunctionException(e, Transience.TRANSIENT);
       }
     }
-    if (sha256Validation != null) {
-      throw sha256Validation;
+    if (checksumValidation != null) {
+      throw checksumValidation;
     }
     env.getListener().post(w);
     DecompressorValue.decompress(
@@ -729,15 +724,8 @@
             .setRepositoryPath(outputPath.getPath())
             .setPrefix(stripPrefix)
             .build());
-    String finalSha256 = null;
-    try {
-      finalSha256 = calculateSha256(sha256, downloadedPath);
-    } catch (IOException e) {
-      throw new RepositoryFunctionException(
-          new IOException(
-              "Couldn't hash downloaded file (" + downloadedPath.getPathString() + ")", e),
-          Transience.PERSISTENT);
-    }
+
+    StructImpl downloadResult = calculateDownloadResult(checksum, downloadedPath);
     try {
       if (downloadedPath.exists()) {
         downloadedPath.delete();
@@ -748,33 +736,84 @@
               "Couldn't delete temporary file (" + downloadedPath.getPathString() + ")", e),
           Transience.TRANSIENT);
     }
-    SkylarkDict<String, Object> dict = SkylarkDict.of(null, "sha256", finalSha256, "success", true);
-    return StructProvider.STRUCT.createStruct(dict, null);
+    return downloadResult;
   }
 
-  private String calculateSha256(String originalSha, Path path)
+  private Checksum calculateChecksum(Optional<Checksum> originalChecksum, Path path)
       throws IOException, InterruptedException {
-    if (!Strings.isNullOrEmpty(originalSha)) {
-      // The sha is checked on download, so if we got here, the user provided sha is good
-      return originalSha;
+    if (originalChecksum.isPresent()) {
+      // The checksum is checked on download, so if we got here, the user provided checksum is good
+      return originalChecksum.get();
     }
-    return RepositoryCache.getChecksum(KeyType.SHA256, path);
+    return Checksum.fromString(KeyType.SHA256, RepositoryCache.getChecksum(KeyType.SHA256, path));
   }
 
-  private RepositoryFunctionException validateSha256(String sha256, Location loc) {
-    if (!sha256.isEmpty() && !KeyType.SHA256.isValid(sha256)) {
-      return new RepositoryFunctionException(
+  private Optional<Checksum> validateChecksum(
+      String sha256, String integrity, List<URL> urls, Location loc)
+      throws RepositoryFunctionException, EvalException {
+    if (!sha256.isEmpty()) {
+      if (!integrity.isEmpty()) {
+        throw new EvalException(loc, "Expected either 'sha256' or 'integrity', but not both");
+      }
+      try {
+        return Optional.of(Checksum.fromString(KeyType.SHA256, sha256));
+      } catch (IllegalArgumentException e) {
+        warnAboutChecksumError(urls, e.getMessage());
+        throw new RepositoryFunctionException(
+            new EvalException(
+                loc,
+                "Definition of repository "
+                    + rule.getName()
+                    + ": "
+                    + e.getMessage()
+                    + " at "
+                    + rule.getLocation()),
+            Transience.PERSISTENT);
+      }
+    }
+
+    if (integrity.isEmpty()) {
+      return Optional.absent();
+    }
+
+    try {
+      return Optional.of(Checksum.fromSubresourceIntegrity(integrity));
+    } catch (IllegalArgumentException e) {
+      warnAboutChecksumError(urls, e.getMessage());
+      throw new RepositoryFunctionException(
           new EvalException(
               loc,
               "Definition of repository "
                   + rule.getName()
-                  + ": Syntactically invalid SHA256 checksum: '"
-                  + sha256
-                  + "' at "
+                  + ": "
+                  + e.getMessage()
+                  + " at "
                   + rule.getLocation()),
           Transience.PERSISTENT);
     }
-    return null;
+  }
+
+  private StructImpl calculateDownloadResult(Optional<Checksum> checksum, Path downloadedPath)
+      throws EvalException, InterruptedException, RepositoryFunctionException {
+    Checksum finalChecksum;
+    try {
+      finalChecksum = calculateChecksum(checksum, downloadedPath);
+    } catch (IOException e) {
+      throw new RepositoryFunctionException(
+          new IOException(
+              "Couldn't hash downloaded file (" + downloadedPath.getPathString() + ")", e),
+          Transience.PERSISTENT);
+    }
+
+    ImmutableMap.Builder<String, Object> out = new ImmutableMap.Builder<>();
+    out.put("success", true);
+    out.put("integrity", finalChecksum.toSubresourceIntegrity());
+
+    // For compatibility with older Bazel versions that don't support non-SHA256 checksums.
+    if (finalChecksum.getKeyType() == KeyType.SHA256) {
+      out.put("sha256", finalChecksum.toString());
+    }
+    return StructProvider.STRUCT.createStruct(SkylarkDict.copyOf(null, out.build()), null);
   }
 
   private static ImmutableList<String> checkAllUrls(Iterable<?> urlList) throws EvalException {
diff --git a/src/main/java/com/google/devtools/build/lib/skylarkbuildapi/repository/SkylarkRepositoryContextApi.java b/src/main/java/com/google/devtools/build/lib/skylarkbuildapi/repository/SkylarkRepositoryContextApi.java
index 31743b3..79c5020 100644
--- a/src/main/java/com/google/devtools/build/lib/skylarkbuildapi/repository/SkylarkRepositoryContextApi.java
+++ b/src/main/java/com/google/devtools/build/lib/skylarkbuildapi/repository/SkylarkRepositoryContextApi.java
@@ -343,8 +343,9 @@
   @SkylarkCallable(
       name = "download",
       doc =
-          "Downloads a file to the output path for the provided url and returns a struct containing"
-              + " a hash of the file with the field <code>sha256</code>.",
+          "Downloads a file to the output path for the provided url and returns a struct"
+              + " containing a hash of the file with the fields <code>sha256</code> and"
+              + " <code>integrity</code>.",
       useLocation = true,
       parameters = {
         @Param(
@@ -404,6 +405,18 @@
             defaultValue = "{}",
             named = true,
             doc = "An optional dict specifying authentication information for some of the URLs."),
+        @Param(
+            name = "integrity",
+            type = String.class,
+            defaultValue = "''",
+            named = true,
+            positional = false,
+            doc =
+                "Expected checksum of the file downloaded, in Subresource Integrity format."
+                    + " This must match the checksum of the file downloaded. It is a security"
+                    + " risk to omit the checksum as remote files can change. At best omitting this"
+                    + " field will make your build non-hermetic. It is optional to make development"
+                    + " easier but should be set before shipping."),
       })
   public StructApi download(
       Object url,
@@ -413,6 +426,7 @@
       Boolean allowFail,
       String canonicalId,
       SkylarkDict<String, SkylarkDict<Object, Object>> auth,
+      String integrity,
       Location location)
       throws RepositoryFunctionExceptionT, EvalException, InterruptedException;
 
@@ -463,8 +477,8 @@
       name = "download_and_extract",
       doc =
           "Downloads a file to the output path for the provided url, extracts it, and returns"
-              + " a struct containing a hash of the downloaded file with the field"
-              + " <code>sha256</code>.",
+              + " a struct containing a hash of the downloaded file with the fields"
+              + " <code>sha256</code> and <code>integrity</code>.",
       useLocation = true,
       parameters = {
         @Param(
@@ -546,6 +560,18 @@
             defaultValue = "{}",
             named = true,
             doc = "An optional dict specifying authentication information for some of the URLs."),
+        @Param(
+            name = "integrity",
+            type = String.class,
+            defaultValue = "''",
+            named = true,
+            positional = false,
+            doc =
+                "Expected checksum of the file downloaded, in Subresource Integrity format."
+                    + " This must match the checksum of the file downloaded. It is a security"
+                    + " risk to omit the checksum as remote files can change. At best omitting this"
+                    + " field will make your build non-hermetic. It is optional to make development"
+                    + " easier but should be set before shipping."),
       })
   public StructApi downloadAndExtract(
       Object url,
@@ -556,6 +582,7 @@
       Boolean allowFail,
       String canonicalId,
       SkylarkDict<String, SkylarkDict<Object, Object>> auth,
+      String integrity,
       Location location)
       throws RepositoryFunctionExceptionT, InterruptedException, EvalException;
 }
diff --git a/src/test/shell/bazel/bazel_workspaces_test.sh b/src/test/shell/bazel/bazel_workspaces_test.sh
index 90a19f6..ef2b84e 100755
--- a/src/test/shell/bazel/bazel_workspaces_test.sh
+++ b/src/test/shell/bazel/bazel_workspaces_test.sh
@@ -223,6 +223,62 @@
   ensure_contains_exactly 'output: "out_for_list.txt"' 1
 }
 
+function test_download_integrity_sha256() {
+  # Prepare HTTP server with Python
+  local server_dir="${TEST_TMPDIR}/server_dir"
+  mkdir -p "${server_dir}"
+  local file="${server_dir}/file.txt"
+  echo "file contents here" > "${file}"
+
+  # Use Python for hashing and encoding due to cross-platform differences in
+  # presence + behavior of `shasum` and `base64`.
+  sha256_py='import base64, hashlib, sys; print(base64.b64encode(hashlib.sha256(sys.stdin.read()).digest()))'
+  file_integrity="sha256-$(cat "${file}" | python -c "${sha256_py}")"
+
+  # Start HTTP server with Python
+  startup_server "${server_dir}"
+
+  set_workspace_command "repository_ctx.download(\"http://localhost:${fileserver_port}/file.txt\", \"file.txt\", integrity=\"${file_integrity}\")"
+
+  build_and_process_log --exclude_rule "//external:local_config_cc"
+
+  ensure_contains_exactly 'location: .*repos.bzl:2:3' 1
+  ensure_contains_atleast 'rule: "//external:repo"' 1
+  ensure_contains_exactly 'download_event' 1
+  ensure_contains_exactly "url: \"http://localhost:${fileserver_port}/file.txt\"" 1
+  ensure_contains_exactly 'output: "file.txt"' 1
+  ensure_contains_exactly "sha256: " 0
+  ensure_contains_exactly "integrity: \"${file_integrity}\"" 1
+}
+
+function test_download_integrity_sha512() {
+  # Prepare HTTP server with Python
+  local server_dir="${TEST_TMPDIR}/server_dir"
+  mkdir -p "${server_dir}"
+  local file="${server_dir}/file.txt"
+  echo "file contents here" > "${file}"
+
+  # Use Python for hashing and encoding due to cross-platform differences in
+  # presence + behavior of `shasum` and `base64`.
+  sha512_py='import base64, hashlib, sys; print(base64.b64encode(hashlib.sha512(sys.stdin.read()).digest()))'
+  file_integrity="sha512-$(cat "${file}" | python -c "${sha512_py}")"
+
+  # Start HTTP server with Python
+  startup_server "${server_dir}"
+
+  set_workspace_command "repository_ctx.download(\"http://localhost:${fileserver_port}/file.txt\", \"file.txt\", integrity=\"${file_integrity}\")"
+
+  build_and_process_log --exclude_rule "//external:local_config_cc"
+
+  ensure_contains_exactly 'location: .*repos.bzl:2:3' 1
+  ensure_contains_atleast 'rule: "//external:repo"' 1
+  ensure_contains_exactly 'download_event' 1
+  ensure_contains_exactly "url: \"http://localhost:${fileserver_port}/file.txt\"" 1
+  ensure_contains_exactly 'output: "file.txt"' 1
+  ensure_contains_exactly "sha256: " 0
+  ensure_contains_exactly "integrity: \"${file_integrity}\"" 1
+}
+
 function test_download_then_extract() {
   # Prepare HTTP server with Python
   local server_dir="${TEST_TMPDIR}/server_dir"
diff --git a/src/test/shell/bazel/external_integration_test.sh b/src/test/shell/bazel/external_integration_test.sh
index bfa4582..708eeb8 100755
--- a/src/test/shell/bazel/external_integration_test.sh
+++ b/src/test/shell/bazel/external_integration_test.sh
@@ -905,7 +905,7 @@
 )
 EOF
   bazel build @repo//... &> $TEST_log && fail "Expected to fail"
-  expect_log "[Ii]nvalid SHA256 checksum"
+  expect_log "[Ii]nvalid SHA-256 checksum"
   shutdown_server
 }