[8.8.0] Prevent NPE crash when updating `MODULE.bazel.lock` with `null` `Repo… (#30895)

…RuleId`.

When a lockfile contains an entry from before commit 3d60f1c or from a
merge conflict resolution that lacks `repoRuleId`, GSON deserializes
`RepoSpec` with `repoRuleId = null`. Later, when Bazel updates and
writes the lockfile in `BazelLockFileModule.updateLockfile`, serializing
`RepoSpec` invokes `repoRuleId.toString()`, which throws an NPE
resulting in a Bazel crash.

Now we have proper `null`-safety checks in GSON TypeAdapters so that
malformed lockfile entries with `null` repoRuleId are cleanly reported.

Fixes https://github.com/bazelbuild/bazel/issues/24716

PiperOrigin-RevId: 971891689
Change-Id: I405c61d5c6f4d20b9908083099a1e64d7f571350

<!--
Thank you for contributing to Bazel!
Please read the contribution guidelines: https://bazel.build/contribute
-->

### Description
<!--
Please provide a brief summary of the changes in this PR.
-->

### Motivation
<!--
Why is this change important? Does it fix a specific bug or add a new
feature?
If this PR fixes an existing issue, please link it here (e.g. "Fixes
#1234").
-->

### Build API Changes
<!--
Does this PR affect the Build API? (e.g. Starlark API, providers,
command-line flags, native rules)
If yes, please answer the following:
1. Has this been discussed in a design doc or issue? (Please link it)
2. Is the change backward compatible?
3. If it's a breaking change, what is the migration plan?
-->

No

### Checklist

- [ ] I have added tests for the new use cases (if any).
- [ ] I have updated the documentation (if applicable).

### Release Notes

<!--
If this is a new feature, please add 'RELNOTES[NEW]: <description>'
here.
If this is a breaking change, please add 'RELNOTES[INC]: <reason>' here.
If this change should be mentioned in release notes, please add
'RELNOTES: <reason>' here.
-->

RELNOTES: None

Commit
https://github.com/bazelbuild/bazel/commit/a671c5e78f4a78196341cf3a487f62ff4101be4f

---------

Co-authored-by: twerth CA <twerth@google.com>
diff --git a/src/main/java/com/google/devtools/build/lib/bazel/bzlmod/BazelLockFileFunction.java b/src/main/java/com/google/devtools/build/lib/bazel/bzlmod/BazelLockFileFunction.java
index 3bbe7b0..7b64a37 100644
--- a/src/main/java/com/google/devtools/build/lib/bazel/bzlmod/BazelLockFileFunction.java
+++ b/src/main/java/com/google/devtools/build/lib/bazel/bzlmod/BazelLockFileFunction.java
@@ -120,7 +120,21 @@
       Matcher matcher = LOCKFILE_VERSION_PATTERN.matcher(json);
       int version = matcher.find() ? Integer.parseInt(matcher.group(1)) : -1;
       if (version == BazelLockFileValue.LOCK_FILE_VERSION) {
-        return GsonTypeAdapterUtil.LOCKFILE_GSON.fromJson(json, BazelLockFileValue.class);
+        BazelLockFileValue lockFileValue =
+            GsonTypeAdapterUtil.LOCKFILE_GSON.fromJson(json, BazelLockFileValue.class);
+        if (!isValidLockfile(lockFileValue)) {
+          if (lockfileMode == LockfileMode.ERROR) {
+            throw new BazelLockfileFunctionException(
+                ExternalDepsException.withMessage(
+                    Code.BAD_LOCKFILE,
+                    "The version of MODULE.bazel.lock is not supported by this version of Bazel."
+                        + " Please run `bazel mod deps --lockfile_mode=update` to update your"
+                        + " lockfile."),
+                Transience.PERSISTENT);
+          }
+          return BazelLockFileValue.EMPTY_LOCKFILE;
+        }
+        return lockFileValue;
       } else {
         // This is an old version, its information can't be used.
         if (lockfileMode == LockfileMode.ERROR) {
@@ -139,6 +153,28 @@
     }
   }
 
+  private static boolean isValidLockfile(@Nullable BazelLockFileValue lockFileValue) {
+    if (lockFileValue == null || lockFileValue.getModuleExtensions() == null) {
+      return false;
+    }
+    for (var extensionMap : lockFileValue.getModuleExtensions().values()) {
+      if (extensionMap == null) {
+        return false;
+      }
+      for (LockFileModuleExtension extension : extensionMap.values()) {
+        if (extension == null || extension.getGeneratedRepoSpecs() == null) {
+          return false;
+        }
+        for (RepoSpec repoSpec : extension.getGeneratedRepoSpecs().values()) {
+          if (repoSpec == null || repoSpec.repoRuleId() == null) {
+            return false;
+          }
+        }
+      }
+    }
+    return true;
+  }
+
   static final class BazelLockfileFunctionException extends SkyFunctionException {
 
     BazelLockfileFunctionException(ExternalDepsException cause, Transience transience) {
diff --git a/src/main/java/com/google/devtools/build/lib/bazel/bzlmod/BazelLockFileModule.java b/src/main/java/com/google/devtools/build/lib/bazel/bzlmod/BazelLockFileModule.java
index c9bcf76..75d16d1 100644
--- a/src/main/java/com/google/devtools/build/lib/bazel/bzlmod/BazelLockFileModule.java
+++ b/src/main/java/com/google/devtools/build/lib/bazel/bzlmod/BazelLockFileModule.java
@@ -307,7 +307,8 @@
    * @param lockfileRoot Root under which the lockfile is located
    * @param updatedLockfile The updated lockfile data to save
    */
-  private static void updateLockfile(Path lockfileRoot, BazelLockFileValue updatedLockfile) {
+  @VisibleForTesting
+  static void updateLockfile(Path lockfileRoot, BazelLockFileValue updatedLockfile) {
     RootedPath lockfilePath =
         RootedPath.toRootedPath(Root.fromPath(lockfileRoot), LabelConstants.MODULE_LOCKFILE_NAME);
     try {
diff --git a/src/main/java/com/google/devtools/build/lib/bazel/bzlmod/GsonTypeAdapterUtil.java b/src/main/java/com/google/devtools/build/lib/bazel/bzlmod/GsonTypeAdapterUtil.java
index 433124f..dc47842 100644
--- a/src/main/java/com/google/devtools/build/lib/bazel/bzlmod/GsonTypeAdapterUtil.java
+++ b/src/main/java/com/google/devtools/build/lib/bazel/bzlmod/GsonTypeAdapterUtil.java
@@ -80,6 +80,10 @@
       new TypeAdapter<>() {
         @Override
         public void write(JsonWriter jsonWriter, ModuleKey moduleKey) throws IOException {
+          if (moduleKey == null) {
+            jsonWriter.nullValue();
+            return;
+          }
           jsonWriter.value(moduleKey.toString());
         }
 
@@ -100,6 +104,10 @@
       new TypeAdapter<>() {
         @Override
         public void write(JsonWriter jsonWriter, Label label) throws IOException {
+          if (label == null) {
+            jsonWriter.nullValue();
+            return;
+          }
           jsonWriter.value(label.getUnambiguousCanonicalForm());
         }
 
@@ -113,6 +121,10 @@
       new TypeAdapter<>() {
         @Override
         public void write(JsonWriter jsonWriter, RepoRuleId repoRuleId) throws IOException {
+          if (repoRuleId == null) {
+            jsonWriter.nullValue();
+            return;
+          }
           jsonWriter.value(repoRuleId.toString());
         }
 
@@ -132,6 +144,10 @@
       new TypeAdapter<>() {
         @Override
         public void write(JsonWriter jsonWriter, RepositoryName repoName) throws IOException {
+          if (repoName == null) {
+            jsonWriter.nullValue();
+            return;
+          }
           jsonWriter.value(repoName.getName());
         }
 
diff --git a/src/test/java/com/google/devtools/build/lib/bazel/bzlmod/BazelLockFileModuleTest.java b/src/test/java/com/google/devtools/build/lib/bazel/bzlmod/BazelLockFileModuleTest.java
index 1b3de52..d57ea7f 100644
--- a/src/test/java/com/google/devtools/build/lib/bazel/bzlmod/BazelLockFileModuleTest.java
+++ b/src/test/java/com/google/devtools/build/lib/bazel/bzlmod/BazelLockFileModuleTest.java
@@ -19,6 +19,8 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.devtools.build.lib.cmdline.Label;
+import com.google.devtools.build.lib.testutil.Scratch;
+import com.google.devtools.build.lib.vfs.Path;
 import java.util.Optional;
 import net.starlark.java.eval.Dict;
 import net.starlark.java.eval.Starlark;
@@ -111,4 +113,26 @@
         .isEqualTo(
             ImmutableMap.of(extensionId, ImmutableMap.of(evalFactors, nonReproducibleResult)));
   }
+
+  @Test
+  public void updateLockfileWithNullRepoRuleIdDoesNotThrow() throws Exception {
+    Path workspaceRoot = new Scratch().dir("/workspace");
+    RepoSpec repoSpecWithNullRuleId = new RepoSpec(null, AttributeValues.create(Dict.empty()));
+    LockFileModuleExtension extensionWithNullRuleId =
+        LockFileModuleExtension.builder()
+            .setBzlTransitiveDigest(new byte[] {1, 2, 3})
+            .setUsagesDigest(new byte[] {4, 5, 6})
+            .setRecordedInputs(ImmutableList.of())
+            .setGeneratedRepoSpecs(ImmutableMap.of("repo", repoSpecWithNullRuleId))
+            .build();
+    BazelLockFileValue lockfile =
+        BazelLockFileValue.builder()
+            .setModuleExtensions(
+                ImmutableMap.of(extensionId, ImmutableMap.of(evalFactors, extensionWithNullRuleId)))
+            .build();
+
+    BazelLockFileModule.updateLockfile(workspaceRoot, lockfile);
+
+    assertThat(workspaceRoot.getRelative("MODULE.bazel.lock").exists()).isTrue();
+  }
 }