Fix double-offset calculation in BoundedByteString.equalsRange.
The code right now is adding this.offset twice so unless it's zero, we'd end up comparing the bytes at 2 * this.offset.

Added tests to cover this use-case.

PiperOrigin-RevId: 1000838699
diff --git a/java/core/src/main/java/com/google/protobuf/ByteString.java b/java/core/src/main/java/com/google/protobuf/ByteString.java
index 1c44438..28ebffe 100644
--- a/java/core/src/main/java/com/google/protobuf/ByteString.java
+++ b/java/core/src/main/java/com/google/protobuf/ByteString.java
@@ -1835,9 +1835,7 @@
         return subArrayEquals(bytes, this.offset, lbsOther.bytes, lbsOther.offset + offset, length);
       }
 
-      return other
-          .substringNoCopy(offset, offset + length)
-          .equals(substringNoCopy(this.offset, this.offset + length));
+      return other.substringNoCopy(offset, offset + length).equals(substringNoCopy(0, length));
     }
 
     @Override
diff --git a/java/core/src/test/java/com/google/protobuf/ByteStringTest.java b/java/core/src/test/java/com/google/protobuf/ByteStringTest.java
index c4e6c6a..ce718f8 100644
--- a/java/core/src/test/java/com/google/protobuf/ByteStringTest.java
+++ b/java/core/src/test/java/com/google/protobuf/ByteStringTest.java
@@ -1121,4 +1121,122 @@
         .that(byteArrayCopier.getClass().getSimpleName().endsWith("ArraysByteArrayCopier"))
         .isTrue();
   }
+
+  private static ByteString wrapDirectNonArrayBacked(byte[] bytes) {
+    ByteBuffer directBuffer = ByteBuffer.allocateDirect(bytes.length);
+    directBuffer.put(bytes);
+    directBuffer.flip();
+    return ByteString.wrap(
+        directBuffer.hasArray() ? directBuffer.asReadOnlyBuffer() : directBuffer,
+        /* copyOffHeap= */ false);
+  }
+
+  @Test
+  public void testBoundedByteStringWithNonZeroOffset_equalsRange() {
+    byte[] backing = new byte[30];
+    for (int i = 0; i < backing.length; i++) {
+      backing[i] = (byte) (i + 1);
+    }
+
+    // Case 1: offset (20) > length (6), content is [21, 22, 23, 24, 25, 26].
+    // Previously threw IndexOutOfBoundsException because substringNoCopy(this.offset,
+    // this.offset + length) exceeded this.length.
+    ByteString.LeafByteString boundedLargeOffset =
+        (ByteString.LeafByteString) ByteString.copyFrom(backing).substring(20, 26);
+    byte[] targetBytes1 =
+        new byte[] {
+          99,
+          99,
+          99,
+          99, // [0..4): padding
+          21,
+          22,
+          23,
+          24,
+          25,
+          26, // [4..10): full match
+          21,
+          22,
+          23,
+          24,
+          99,
+          99, // [10..16): 4-byte prefix match, full mismatch
+          88,
+          88,
+          88,
+          88 // [16..20): padding
+        };
+    ByteString rope1 =
+        RopeByteString.newInstanceForTest(
+            ByteString.copyFrom(targetBytes1, 0, 7),
+            ByteString.copyFrom(targetBytes1, 7, targetBytes1.length - 7));
+    ByteString nonArrayBacked1 = wrapDirectNonArrayBacked(targetBytes1);
+
+    for (ByteString other : new ByteString[] {rope1, nonArrayBacked1}) {
+      // Full length (length == boundedLargeOffset.size())
+      assertThat(boundedLargeOffset.equalsRange(other, 4, 6)).isTrue();
+      assertThat(boundedLargeOffset.equalsRange(other, 10, 6)).isFalse();
+      assertThat(boundedLargeOffset.equalsRange(other, 0, 6)).isFalse();
+
+      // Partial length (length < boundedLargeOffset.size())
+      assertThat(boundedLargeOffset.equalsRange(other, 4, 4)).isTrue();
+      assertThat(boundedLargeOffset.equalsRange(other, 10, 4)).isTrue();
+      assertThat(boundedLargeOffset.equalsRange(other, 0, 4)).isFalse();
+    }
+
+    // Case 2: 0 < offset (3) < length (12), content is [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15].
+    // For partial length 5 (offset + 5 <= 12), previously compared the wrong slice at 2 * offset
+    // ([7, 8, 9, 10, 11]) instead of [4, 5, 6, 7, 8].
+    ByteString.LeafByteString boundedSmallOffset =
+        (ByteString.LeafByteString) ByteString.copyFrom(backing).substring(3, 15);
+    byte[] targetBytes2 =
+        new byte[] {
+          99,
+          99, // [0..2): padding
+          4,
+          5,
+          6,
+          7,
+          8,
+          9,
+          10,
+          11,
+          12,
+          13,
+          14,
+          15, // [2..14): full 12-byte match
+          7,
+          8,
+          9,
+          10,
+          11, // [14..19): bytes at 2 * offset (must not match)
+          4,
+          5,
+          6,
+          7,
+          8,
+          9,
+          10,
+          11,
+          12,
+          13,
+          14,
+          99 // [19..31): 12-byte mismatch at last byte
+        };
+    ByteString rope2 =
+        RopeByteString.newInstanceForTest(
+            ByteString.copyFrom(targetBytes2, 0, 8),
+            ByteString.copyFrom(targetBytes2, 8, targetBytes2.length - 8));
+    ByteString nonArrayBacked2 = wrapDirectNonArrayBacked(targetBytes2);
+
+    for (ByteString other : new ByteString[] {rope2, nonArrayBacked2}) {
+      // Full length (length == boundedSmallOffset.size())
+      assertThat(boundedSmallOffset.equalsRange(other, 2, 12)).isTrue();
+      assertThat(boundedSmallOffset.equalsRange(other, 19, 12)).isFalse();
+
+      // Partial length (length < boundedSmallOffset.size(), offset + length <= size())
+      assertThat(boundedSmallOffset.equalsRange(other, 2, 5)).isTrue();
+      assertThat(boundedSmallOffset.equalsRange(other, 14, 5)).isFalse();
+    }
+  }
 }