fix: convert hex from packageManager integrity to base64 format (#2717)

The hash on the `packageManager` field in `package.json` is specified as
a hex string for the sha512 checksum. This representation is what
corepack

[expects](https://github.com/nodejs/corepack?tab=readme-ov-file#when-authoring-packages)
and what something like `pnpm install` can understand. However, Bazel
needs an integrity hash in SRI format with a base64 representation of
the checksum. The repository code will now convert from hex to base64 on
the fly. This makes the `pnpm_version_from` field actually useful with a
hash. Before it was only useful without it since the hash format was
mismatched between corepack and Bazel and thus caused errors on one side
or the other.

Cherry-Pick from #2709.

---

### Changes are visible to end-users: yes

- Searched for relevant documentation and updated as needed: yes
- Breaking change (forces users to change their own code or config): no
- Suggested release notes appear below: no

### Test plan

- New test cases added
diff --git a/e2e/update_pnpm_lock/.aspect/external_repository_action_cache/npm_translate_lock_LTE4Nzc1MDcwNjU= b/e2e/update_pnpm_lock/.aspect/external_repository_action_cache/npm_translate_lock_LTE4Nzc1MDcwNjU=
index 2c03619..5d60cd1 100755
--- a/e2e/update_pnpm_lock/.aspect/external_repository_action_cache/npm_translate_lock_LTE4Nzc1MDcwNjU=
+++ b/e2e/update_pnpm_lock/.aspect/external_repository_action_cache/npm_translate_lock_LTE4Nzc1MDcwNjU=
@@ -2,7 +2,7 @@
 # Input hashes for repository rule npm_translate_lock(name = "npm", pnpm_lock = "@@//:pnpm-lock.yaml").
 # This file should be checked into version control along with the pnpm-lock.yaml file.
 .npmrc=664934919
-package.json=-1406666059
+package.json=68550237
 pnpm-lock.yaml=-1664994187
 pnpm-workspace.yaml=-79388955
 workspace_package/package.json=-643240351
diff --git a/e2e/update_pnpm_lock/MODULE.bazel b/e2e/update_pnpm_lock/MODULE.bazel
index 848beb7..601a15f 100644
--- a/e2e/update_pnpm_lock/MODULE.bazel
+++ b/e2e/update_pnpm_lock/MODULE.bazel
@@ -9,7 +9,7 @@
 pnpm = use_extension("@aspect_rules_js//npm:extensions.bzl", "pnpm")
 pnpm.pnpm(
     name = "pnpm",
-    pnpm_version = "9.15.9",
+    pnpm_version_from = "//:package.json",
 )
 use_repo(pnpm, "pnpm", "pnpm__links")
 
diff --git a/e2e/update_pnpm_lock/package.json b/e2e/update_pnpm_lock/package.json
index 521c678..448a6c4 100644
--- a/e2e/update_pnpm_lock/package.json
+++ b/e2e/update_pnpm_lock/package.json
@@ -1,5 +1,6 @@
 {
     "private": true,
+    "packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264",
     "pnpm": {
         "onlyBuiltDependencies": []
     },
diff --git a/npm/private/pnpm_extension.bzl b/npm/private/pnpm_extension.bzl
index 6366029..b0277e7 100644
--- a/npm/private/pnpm_extension.bzl
+++ b/npm/private/pnpm_extension.bzl
@@ -2,6 +2,7 @@
 
 load("@aspect_bazel_lib//lib:lists.bzl", "unique")
 load(":pnpm_repository.bzl", "DEFAULT_PNPM_VERSION", "LATEST_PNPM_VERSION")
+load(":utils.bzl", "utils")
 
 DEFAULT_PNPM_REPO_NAME = "pnpm"
 
@@ -62,8 +63,10 @@
                     parts = v.rsplit("+sha512.", 1)
                     v = parts[0]
 
-                    # Store the integrity hash (prepend "sha512-" as that's the expected format)
-                    integrity[v] = "sha512-" + parts[1]
+                    # Store the integrity hash. We need to convert the hex representation of the
+                    # hash used by corepack, to the one Bazel understands: base64 encoded with a
+                    # "sha512-" prefix.
+                    integrity[v] = "sha512-" + utils.hex_to_base64(parts[1])
 
             elif attr.pnpm_version == "latest":
                 v = LATEST_PNPM_VERSION
diff --git a/npm/private/test/pnpm_test.bzl b/npm/private/test/pnpm_test.bzl
index c2e76ec..7d3cd29 100644
--- a/npm/private/test/pnpm_test.bzl
+++ b/npm/private/test/pnpm_test.bzl
@@ -60,11 +60,12 @@
     )
 
 def _from_package_json_with_hash(ctx):
-    # Test reading pnpm version from package.json with integrity hash.
-    # packageManager: "pnpm@1.2.3+sha512.xxx" -> (version, integrity) tuple
+    # Test reading pnpm version from package.json with integrity hash in the hexadecimal format
+    # that is standard for corepack. The integrity needs to be in SRI format (base64).
+    # packageManager: "pnpm@1.2.3+sha512.<base64>" -> (version, integrity) tuple
     return _resolve_test(
         ctx,
-        repositories = {"pnpm": ("1.2.3", "sha512-97462997561378b6f52ac5c614f3a3b923a652ad5ac987100286e4aa2d84a6a0642e9e45f3d01d30c46b12b20beb0f86aeb790bf9a82bc59db42b67fe69d1a25")},
+        repositories = {"pnpm": ("1.2.3", "sha512-l0Ypl1YTeLb1KsXGFPOjuSOmUq1ayYcQAobkqi2EpqBkLp5F89AdMMRrErIL6w+GrreQv5qCvFnbQrZ/5p0aJQ==")},
         modules = [
             _fake_mod(True, _fake_pnpm_tag(pnpm_version_from = "//:package.json")),
         ],
diff --git a/npm/private/test/utils_tests.bzl b/npm/private/test/utils_tests.bzl
index 63ed354..113c125 100644
--- a/npm/private/test/utils_tests.bzl
+++ b/npm/private/test/utils_tests.bzl
@@ -126,6 +126,24 @@
     )
     return unittest.end(env)
 
+# buildifier: disable=function-docstring
+def test_hex_to_base64(ctx):
+    given_expected = {
+        "382877d089ed5e47e31d364e0dc88c163e8a8e5e8e6aeb6b537e9f77931394d89fb142f1d1d18d32536e3added79d98241048a700b1cfbce9d7167777fa8c502": "OCh30IntXkfjHTZODciMFj6Kjl6OautrU36fd5MTlNifsULx0dGNMlNuOt3tedmCQQSKcAsc+86dcWd3f6jFAg==",
+        "cf78dac1faa7faafffb89023bdf584c5cc4e219db349c376a7afc93f1dc00a08fa365732dae800646f8d99aeaa665ae85596d721c424efface8d25889f07c870": "z3jawfqn+q//uJAjvfWExcxOIZ2zScN2p6/JPx3ACgj6Nlcy2ugAZG+Nma6qZlroVZbXIcQk7/rOjSWInwfIcA==",
+        "bf28e5b00a825846c3b50e57ca6468d2a0f86ba9703be0193898d15ad5807203b7982408861e1f80275325dc6aa40bd06a7889c8b71166a072fc0e5fe0e5db29": "vyjlsAqCWEbDtQ5XymRo0qD4a6lwO+AZOJjRWtWAcgO3mCQIhh4fgCdTJdxqpAvQaniJyLcRZqBy/A5f4OXbKQ==",
+        "a3aeb971bcc746dd0c2c9b2050745833ee09c2c7d173f1d8e357b37239db2faf59deb5bab122754d874bd54a31c473c5af1b4096375de5501bc11e3f86e14392": "o665cbzHRt0MLJsgUHRYM+4JwsfRc/HY41ezcjnbL69Z3rW6sSJ1TYdL1UoxxHPFrxtAljdd5VAbwR4/huFDkg==",
+        "0f3707ebd2828c3e96e492629d6b3efcb4c03f71cb662fe4db432170a6fb622e14fb0647f5e1db307a282482ded220a2ccdfda92d6a652269e207e72c45ea5d6": "DzcH69KCjD6W5JJinWs+/LTAP3HLZi/k20MhcKb7Yi4U+wZH9eHbMHooJILe0iCizN/aktamUiaeIH5yxF6l1g==",
+    }
+    env = unittest.begin(ctx)
+    for given, expected in given_expected.items():
+        asserts.equals(
+            env,
+            expected,
+            utils.hex_to_base64(given),
+        )
+    return unittest.end(env)
+
 t1_test = unittest.make(test_bazel_name)
 t2_test = unittest.make(test_pnpm_name)
 t3_test = unittest.make(test_friendly_name)
@@ -134,6 +152,7 @@
 t7_test = unittest.make(test_npm_registry_download_url)
 t8_test = unittest.make(test_npm_registry_url)
 t9_test = unittest.make(test_link_version)
+t10_test = unittest.make(test_hex_to_base64)
 
 def utils_tests(name):
     unittest.suite(
@@ -146,4 +165,5 @@
         t7_test,
         t8_test,
         t9_test,
+        t10_test,
     )
diff --git a/npm/private/utils.bzl b/npm/private/utils.bzl
index 2b73f82..257ec2c 100644
--- a/npm/private/utils.bzl
+++ b/npm/private/utils.bzl
@@ -10,6 +10,9 @@
 DEFAULT_REGISTRY_PROTOCOL = "https"
 DEFAULT_EXTERNAL_REPOSITORY_ACTION_CACHE = ".aspect/rules/external_repository_action_cache"
 
+# Alphabet for base64 strings.
+_B64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
+
 def _sorted_map(m):
     # TODO(zbarsky): maybe faster as `dict(sorted(m.items()))`?
     return {k: m[k] for k in sorted(m.keys())}
@@ -237,6 +240,64 @@
     ]
     return ext in tarball_extensions
 
+def _hex_to_base64(hex_string):
+    """Converts a non-delimited hex string (like a SHA-512 checksum) to base64."""
+
+    # 1. Convert hex string to a list of integer bytes
+    bytes_list = []
+
+    # Loop with step 2 to grab hex pairs
+    for i in range(0, len(hex_string), 2):
+        bytes_list.append(int(hex_string[i:i + 2], 16))
+
+    output = []
+    length = len(bytes_list)
+
+    # 2. Process bytes in chunks of 3 using range(start, stop, step)
+    for i in range(0, length, 3):
+        b1 = bytes_list[i]
+
+        # Check if 2nd byte exists
+        if i + 1 < length:
+            b2 = bytes_list[i + 1]
+        else:
+            b2 = -1
+
+        # Check if 3rd byte exists
+        if i + 2 < length:
+            b3 = bytes_list[i + 2]
+        else:
+            b3 = -1
+
+        # Construct 24-bit buffer
+        # Use 0 for missing bytes during bitwise ops
+        val = (b1 << 16) | ((b2 if b2 != -1 else 0) << 8) | (b3 if b3 != -1 else 0)
+
+        # Extract 6-bit indices
+        c1 = (val >> 18) & 0x3F
+        c2 = (val >> 12) & 0x3F
+        c3 = (val >> 6) & 0x3F
+        c4 = val & 0x3F
+
+        output.append(_B64_CHARS[c1])
+        output.append(_B64_CHARS[c2])
+
+        # Handle Padding
+        if b2 == -1:
+            # Only 1 byte available -> Pad 2
+            output.append("=")
+            output.append("=")
+        elif b3 == -1:
+            # Only 2 bytes available -> Pad 1
+            output.append(_B64_CHARS[c3])
+            output.append("=")
+        else:
+            # All 3 bytes available -> No padding
+            output.append(_B64_CHARS[c3])
+            output.append(_B64_CHARS[c4])
+
+    return "".join(output)
+
 utils = struct(
     bazel_name = _bazel_name,
     sorted_map = _sorted_map,
@@ -261,6 +322,7 @@
     exists = _exists,
     replace_npmrc_token_envvar = _replace_npmrc_token_envvar,
     is_tarball_extension = _is_tarball_extension,
+    hex_to_base64 = _hex_to_base64,
 )
 
 # Exported only to be tested