diff --git a/java/core/BUILD.bazel b/java/core/BUILD.bazel
index 43053bd0d91bf..8da283ee6ed09 100644
--- a/java/core/BUILD.bazel
+++ b/java/core/BUILD.bazel
@@ -1,6 +1,5 @@
load("@bazel_skylib//rules:build_test.bzl", "build_test")
load("@rules_java//java:java_library.bzl", "java_library")
-load("@rules_pkg//pkg:mappings.bzl", "pkg_files", "strip_prefix")
load("//:protobuf.bzl", "internal_gen_well_known_protos_java")
load("//:protobuf_version.bzl", "PROTOBUF_JAVA_VERSION")
load("//bazel:cc_proto_library.bzl", "cc_proto_library")
@@ -55,6 +54,7 @@ LITE_SRCS = [
"src/main/java/com/google/protobuf/Internal.java",
"src/main/java/com/google/protobuf/InternalLazyField.java",
"src/main/java/com/google/protobuf/InvalidProtocolBufferException.java",
+ "src/main/java/com/google/protobuf/InvalidProtobufRuntimeException.java",
"src/main/java/com/google/protobuf/IterableByteBufferInputStream.java",
"src/main/java/com/google/protobuf/Java8Compatibility.java",
"src/main/java/com/google/protobuf/JavaType.java",
diff --git a/java/core/src/main/java/com/google/protobuf/ExtensionRegistryLite.java b/java/core/src/main/java/com/google/protobuf/ExtensionRegistryLite.java
index 951575e5032d1..61af0afd3ff96 100644
--- a/java/core/src/main/java/com/google/protobuf/ExtensionRegistryLite.java
+++ b/java/core/src/main/java/com/google/protobuf/ExtensionRegistryLite.java
@@ -53,6 +53,27 @@ public class ExtensionRegistryLite {
// applications. Need to support this feature on smaller granularity.
private static volatile boolean eagerlyParseMessageSets = false;
+ enum LazyExtensionMode {
+ EAGER,
+ // Caution: This mode is unsafe as it postpone parsing errors such as required fields missing
+ // until first access.
+ UNVERIFIED_LAZY;
+ }
+
+ private static volatile LazyExtensionMode lazyExtensionMode = LazyExtensionMode.EAGER;
+
+ static void setLazyExtensionMode(LazyExtensionMode mode) {
+ lazyExtensionMode = mode;
+ }
+
+ static LazyExtensionMode getLazyExtensionMode() {
+ return lazyExtensionMode;
+ }
+
+ static boolean lazyExtensionEnabled() {
+ return lazyExtensionMode == LazyExtensionMode.UNVERIFIED_LAZY;
+ }
+
// Visible for testing.
static final String EXTENSION_CLASS_NAME = "com.google.protobuf.Extension";
diff --git a/java/core/src/main/java/com/google/protobuf/InternalLazyField.java b/java/core/src/main/java/com/google/protobuf/InternalLazyField.java
index f4377d6782c1b..05a3c439643ed 100644
--- a/java/core/src/main/java/com/google/protobuf/InternalLazyField.java
+++ b/java/core/src/main/java/com/google/protobuf/InternalLazyField.java
@@ -1,3 +1,10 @@
+// Protocol Buffers - Google's data interchange format
+// Copyright 2026 Google Inc. All rights reserved.
+//
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file or at
+// https://developers.google.com/open-source/licenses/bsd
+
package com.google.protobuf;
import java.io.IOException;
@@ -29,7 +36,9 @@ class InternalLazyField {
protected volatile MessageLite value;
// Whether the lazy field (i.e. {@link bytes}) was corrupted, and if so, {@link value} must be
- // `null`.
+ // `null`. This is used to avoid repeat parsing attempts on invalid bytes.
+ // TODO: b/473034710 - Can we drop this field as it might be unreal for people to re-parse the
+ // bytes, especially an exception has already been thrown?
private volatile boolean corrupted;
/** Constructor for InternalLazyField. All arguments cannot be null. */
@@ -65,42 +74,178 @@ class InternalLazyField {
this.corrupted = false;
}
- private boolean containsEmptyBytes() {
- return bytes == null || bytes.isEmpty();
+ /**
+ * Merges the InternalLazyField from the given CodedInputStream with the given extension registry.
+ *
+ *
Precondition: the input stream should have already read the tag and be expected to read the
+ * size of the bytes next.
+ *
+ * @throws IOException only if an error occurs while reading the raw bytes from the input stream,
+ * not for failures in parsing the read bytes.
+ * @throws InvalidProtobufRuntimeException if the lazy field is corrupted and cannot be merged
+ * with a different extension registry.
+ */
+ static InternalLazyField mergeFrom(
+ InternalLazyField lazyField, CodedInputStream input, ExtensionRegistryLite extensionRegistry)
+ throws IOException {
+ ByteString inputBytes = input.readBytes();
+ if (lazyField.isEmpty()) {
+ return new InternalLazyField(lazyField.defaultInstance, extensionRegistry, inputBytes);
+ }
+
+ if (inputBytes.isEmpty()) {
+ return lazyField;
+ }
+
+ if (lazyField.hasBytes()) {
+ if (lazyField.extensionRegistry == extensionRegistry) {
+ return new InternalLazyField(
+ lazyField.defaultInstance, extensionRegistry, lazyField.bytes.concat(inputBytes));
+ }
+ // The extension registries are different, so we need to parse the bytes first to "consume"
+ // the extension registry of this lazy field.
+ try {
+ lazyField.ensureInitialized();
+ } catch (InvalidProtocolBufferException e) {
+ throw new InvalidProtobufRuntimeException(
+ "Cannot merge invalid lazy field from bytes that is absent or with a different"
+ + " extension registry.",
+ e);
+ }
+ }
+
+ // TODO: b/473034710 - Consider avoiding the copy as it's parsing into a message.
+ return mergeValueFromBytes(lazyField, inputBytes, extensionRegistry);
}
- private void initializeValue() {
+ /**
+ * Merges a InternalLazyField from another InternalLazyField.
+ *
+ * @throws InvalidProtobufRuntimeException if either lazy field is corrupted and cannot be merged
+ * with a different extension registry.
+ */
+ static InternalLazyField mergeFrom(InternalLazyField self, InternalLazyField other) {
+ if (self.defaultInstance != other.defaultInstance) {
+ throw new IllegalArgumentException(
+ "LazyFields with different default instances cannot be merged.");
+ }
+
+ // If either InternalLazyField is empty, return the other InternalLazyField.
+ if (self.isEmpty()) {
+ return other;
+ }
+ if (other.isEmpty()) {
+ return self;
+ }
+
+ // Fast path: concatenate the bytes if both LazyFields contain bytes and have the same extension
+ // registry, even if one or both are corrupted.
+ if (self.hasBytes() && other.hasBytes() && self.extensionRegistry == other.extensionRegistry) {
+ return new InternalLazyField(
+ self.defaultInstance, self.extensionRegistry, self.bytes.concat(other.bytes));
+ }
+
+ // Cannot concatenate the bytes right way. Try initializing the value first and merge from
+ // the other.
+ try {
+ self.ensureInitialized();
+ } catch (InvalidProtocolBufferException e) {
+ // The self lazy field is corrupted, meaning that it contains invalid bytes to be
+ // concatenated. However, we should only do so if other has bytes and the extension registries
+ // are the same. Which has already been handled above.
+ throw new InvalidProtobufRuntimeException(
+ "Cannot merge invalid lazy field from bytes that is absent or with a different"
+ + " extension registry.",
+ e);
+ }
+
+ // Merge from the other depending on whether the other contains bytes or value.
+ if (other.value != null) {
+ return new InternalLazyField(self.value.toBuilder().mergeFrom(other.value).build());
+ }
+
+ // Since other.value is null, other.bytes must be non-null.
+ return mergeValueFromBytes(self, other.bytes, other.extensionRegistry);
+ }
+
+ /**
+ * Merges the InternalLazyField from the given ByteString with the given extension registry.
+ *
+ *
It can throw a runtime exception if it cannot parse the bytes.
+ */
+ private static InternalLazyField mergeValueFromBytes(
+ InternalLazyField self, ByteString inputBytes, ExtensionRegistryLite extensionRegistry) {
+ try {
+ return new InternalLazyField(
+ self.value.toBuilder().mergeFrom(inputBytes, extensionRegistry).build());
+ } catch (InvalidProtocolBufferException e) {
+ // If the input bytes is corrupted, we should cancat bytes. However, we should only do so if
+ // the extension registries are the same AND self.bytes is present. This should have been
+ // handled in the mergeFrom function, so we just throw an exception here.
+ throw new InvalidProtobufRuntimeException(
+ "Cannot merge lazy field from invalid bytes with a different extension registry.", e);
+ }
+ }
+
+ private boolean hasBytes() {
+ return bytes != null;
+ }
+
+ private boolean isEmpty() {
+ // Assumes that bytes and value cannot be null at the same time.
+ return bytes != null ? bytes.isEmpty() : value.equals(defaultInstance);
+ }
+
+ /**
+ * Guarantees that `this.value` is non-null or throws.
+ *
+ * @throws {InvalidProtocolBufferException} If `bytes` cannot be parsed.
+ */
+ private void ensureInitialized() throws InvalidProtocolBufferException {
if (value != null) {
return;
}
synchronized (this) {
if (corrupted) {
- return;
+ throw new InvalidProtocolBufferException("Repeat access to corrupted lazy field");
}
try {
- if (!containsEmptyBytes()) {
- value = defaultInstance.getParserForType().parseFrom(bytes, extensionRegistry);
- } else {
- value = defaultInstance;
- }
+ // `Bytes` is guaranteed to be non-null since `value` was null.
+ value = defaultInstance.getParserForType().parseFrom(bytes, extensionRegistry);
} catch (InvalidProtocolBufferException e) {
corrupted = true;
- value = null;
+ throw e;
}
}
}
+ /**
+ * Returns the parsed message value.
+ *
+ *
If the lazy field is corrupted, it will throw a runtime exception if {@link
+ * ExtensionRegistryLite#lazyExtensionEnabled()} is true, otherwise it will return the default
+ * instance.
+ */
MessageLite getValue() {
- initializeValue();
- return value == null ? defaultInstance : value;
+ try {
+ ensureInitialized();
+ return value;
+ } catch (InvalidProtocolBufferException e) {
+ if (ExtensionRegistryLite.lazyExtensionEnabled()) {
+ // New behavior: runtime exception on corrupted extensions.
+ throw new InvalidProtobufRuntimeException(e);
+ } else {
+ // Old behavior: silently return the default instance.
+ return defaultInstance;
+ }
+ }
}
+ // TODO: b/473034710 - Consider returning `toByteString().size()` as an optimization to
+ // materialize the bytes expecting `toByteString()` to be called right after.
int getSerializedSize() {
- if (bytes != null) {
- return bytes.size();
- }
- return value.getSerializedSize();
+ return bytes != null ? bytes.size() : value.getSerializedSize();
}
ByteString toByteString() {
diff --git a/java/core/src/main/java/com/google/protobuf/InvalidProtobufRuntimeException.java b/java/core/src/main/java/com/google/protobuf/InvalidProtobufRuntimeException.java
new file mode 100644
index 0000000000000..572c6eaeb346d
--- /dev/null
+++ b/java/core/src/main/java/com/google/protobuf/InvalidProtobufRuntimeException.java
@@ -0,0 +1,28 @@
+// Protocol Buffers - Google's data interchange format
+// Copyright 2026 Google Inc. All rights reserved.
+//
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file or at
+// https://developers.google.com/open-source/licenses/bsd
+
+package com.google.protobuf;
+
+/**
+ * Thrown when a protocol message being parsed is invalid in some way, but a checked exception is
+ * not allowed. For instance, in a lazy field value getter.
+ */
+public class InvalidProtobufRuntimeException extends RuntimeException {
+ private static final long serialVersionUID = 1L;
+
+ public InvalidProtobufRuntimeException(String description) {
+ super(description);
+ }
+
+ public InvalidProtobufRuntimeException(Exception e) {
+ super(e.getMessage(), e);
+ }
+
+ public InvalidProtobufRuntimeException(String description, Exception e) {
+ super(description, e);
+ }
+}
diff --git a/java/core/src/test/java/com/google/protobuf/InternalLazyFieldTest.java b/java/core/src/test/java/com/google/protobuf/InternalLazyFieldTest.java
index de5a62b9ffe7b..023cfd97925d1 100644
--- a/java/core/src/test/java/com/google/protobuf/InternalLazyFieldTest.java
+++ b/java/core/src/test/java/com/google/protobuf/InternalLazyFieldTest.java
@@ -1,18 +1,61 @@
+// Protocol Buffers - Google's data interchange format
+// Copyright 2026 Google Inc. All rights reserved.
+//
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file or at
+// https://developers.google.com/open-source/licenses/bsd
+
package com.google.protobuf;
import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+import proto2_unittest.UnittestProto;
import proto2_unittest.UnittestProto.TestAllExtensions;
import proto2_unittest.UnittestProto.TestAllTypes;
+import proto2_unittest.UnittestProto.TestRequired;
+import java.io.ByteArrayOutputStream;
+import java.util.Arrays;
+import java.util.List;
+import org.junit.After;
+import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
-import org.junit.runners.JUnit4;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameters;
-@RunWith(JUnit4.class)
+@RunWith(Parameterized.class)
public final class InternalLazyFieldTest {
private static final ExtensionRegistryLite EXTENSION_REGISTRY = TestUtil.getExtensionRegistry();
+ private final ExtensionRegistryLite.LazyExtensionMode mode;
+ private final ExtensionRegistryLite.LazyExtensionMode defaultMode;
+
+ public InternalLazyFieldTest(ExtensionRegistryLite.LazyExtensionMode mode) {
+ this.defaultMode = ExtensionRegistryLite.getLazyExtensionMode();
+ this.mode = mode;
+ }
+
+ @Parameters
+ public static List