This is an automated email from the ASF dual-hosted git repository.

jojochuang pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/ozone.git


The following commit(s) were added to refs/heads/master by this push:
     new d0e0b10fc02 HDDS-16426. Fix thread-safety of positioned reads in 
OzoneCryptoInputStream (#11251)
d0e0b10fc02 is described below

commit d0e0b10fc0247d48287e006d87e1a78a1fa64220
Author: Tak Lon (Stephen) Wu <[email protected]>
AuthorDate: Mon Sep 21 15:19:35 2026 -0700

    HDDS-16426. Fix thread-safety of positioned reads in OzoneCryptoInputStream 
(#11251)
---
 .../ozone/client/io/OzoneCryptoInputStream.java    | 159 ++++++-
 .../client/io/TestOzoneCryptoInputStream.java      | 504 +++++++++++++++++++++
 2 files changed, 662 insertions(+), 1 deletion(-)

diff --git 
a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/OzoneCryptoInputStream.java
 
b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/OzoneCryptoInputStream.java
index 521c1d9816e..ab8617e30f4 100644
--- 
a/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/OzoneCryptoInputStream.java
+++ 
b/hadoop-ozone/client/src/main/java/org/apache/hadoop/ozone/client/io/OzoneCryptoInputStream.java
@@ -18,10 +18,14 @@
 package org.apache.hadoop.ozone.client.io;
 
 import com.google.common.base.Preconditions;
+import java.io.EOFException;
 import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ReadOnlyBufferException;
 import org.apache.hadoop.crypto.CryptoCodec;
 import org.apache.hadoop.crypto.CryptoInputStream;
 import org.apache.hadoop.crypto.CryptoStreamUtils;
+import org.apache.hadoop.fs.FSExceptionMessages;
 import org.apache.hadoop.hdds.scm.storage.PartInputStream;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -36,6 +40,8 @@ public class OzoneCryptoInputStream extends CryptoInputStream
   private static final Logger LOG =
       LoggerFactory.getLogger(OzoneCryptoInputStream.class);
 
+  private static final int EOF = -1;
+
   private final long length;
   private final int bufferSize;
   private final String keyName;
@@ -73,8 +79,19 @@ public int getBufferSize() {
     return bufferSize;
   }
 
+  /**
+   * {@link CryptoInputStream} does not synchronize its own methods, so every 
method moving the cursor of
+   * this stream is serialized here on the monitor of this stream. Otherwise a 
read or a seek could land in
+   * the middle of a positioned read and see (or undo) the cursor move that 
read does.
+   * This covers both the sequential-read API ({@link #read()}, {@link 
#read(byte[], int, int)},
+   * {@link #read(ByteBuffer)}, {@link #seek(long)}, {@link #getPos()}, {@link 
#skip(long)}) and all
+   * positioned-read overloads
+   * ({@link #read(long, ByteBuffer)}, {@link #readFully(long, ByteBuffer)},
+   * {@link #read(long, byte[], int, int)}, {@link #readFully(long, byte[], 
int, int)},
+   * {@link #readFully(long, byte[])}).
+   */
   @Override
-  public int read(byte[] b, int off, int len) throws IOException {
+  public synchronized int read(byte[] b, int off, int len) throws IOException {
     // CryptoInputStream reads hadoop.security.crypto.buffer.size number of
     // bytes (default 8KB) at a time. This needs to be taken into account
     // in calculating the numBytesToRead.
@@ -136,6 +153,146 @@ keyName, partIndex, getLength(), numBytesToRead,
     return numBytesRead;
   }
 
+  @Override
+  public synchronized int read(ByteBuffer buf) throws IOException {
+    return super.read(buf);
+  }
+
+  @Override
+  public synchronized void seek(long pos) throws IOException {
+    super.seek(pos);
+  }
+
+  @Override
+  public synchronized long getPos() throws IOException {
+    return super.getPos();
+  }
+
+  @Override
+  public synchronized long skip(long n) throws IOException {
+    return super.skip(n);
+  }
+
+  @Override
+  public synchronized boolean seekToNewSource(long targetPos) throws 
IOException {
+    return super.seekToNewSource(targetPos);
+  }
+
+  @Override
+  public synchronized int read() throws IOException {
+    return super.read();
+  }
+
+  @Override
+  public synchronized int available() throws IOException {
+    return super.available();
+  }
+
+  /**
+   * Positioned read. Decryption can only happen at Crypto buffer boundaries, 
so this stream cannot read
+   * at an arbitrary position without moving its cursor. The read is 
serialized against the other reads:
+   * the cursor is moved to {@code position}, data is read via {@link 
#read(byte[], int, int)} (which
+   * handles the Crypto boundary adjustment), and the cursor is restored 
before the lock is released.
+   *
+   * <p>Returns -1 for {@code position >= length}. A read-only {@code dst} is 
rejected before any state
+   * is mutated. If any exception escapes the read loop the crypto-boundary 
adjustment fields are reset so
+   * the next sequential read does not fail the precondition in {@code 
getNumBytesToRead}.
+   */
+  @Override
+  public synchronized int read(long position, ByteBuffer dst) throws 
IOException {
+    if (!dst.hasRemaining()) {
+      return 0;
+    }
+    if (dst.isReadOnly()) {
+      throw new ReadOnlyBufferException();
+    }
+    if (position < 0) {
+      throw new EOFException("The given position is negative: " + position);
+    }
+    if (position >= getLength()) {
+      return EOF;
+    }
+
+    final long oldPos = getPos();
+    final int readLength = (int) Math.min(dst.remaining(), getLength() - 
position);
+    final byte[] buffer = new byte[Math.min(readLength, getBufferSize())];
+    Throwable failure = null;
+    try {
+      seek(position);
+      int totalReadLen = 0;
+      while (totalReadLen < readLength) {
+        final int numBytesRead = read(buffer, 0,
+            Math.min(buffer.length, readLength - totalReadLen));
+        if (numBytesRead <= 0) {
+          break;
+        }
+        dst.put(buffer, 0, numBytesRead);
+        totalReadLen += numBytesRead;
+      }
+      return totalReadLen == 0 ? EOF : totalReadLen;
+    } catch (Throwable t) {
+      failure = t;
+      // Defensively reset crypto-boundary adjustment fields so that the next
+      // sequential read does not fail the precondition check in 
getNumBytesToRead.
+      readPositionAdjustedBy = 0;
+      readLengthAdjustedBy = 0;
+      throw t;
+    } finally {
+      try {
+        seek(oldPos);
+      } catch (IOException e) {
+        if (failure == null) {
+          throw e;
+        }
+        failure.addSuppressed(e);
+      }
+    }
+  }
+
+  @Override
+  public synchronized void readFully(long position, ByteBuffer dst) throws 
IOException {
+    int bytesRead;
+    for (int readCount = 0; dst.hasRemaining(); readCount += bytesRead) {
+      bytesRead = read(position + readCount, dst);
+      if (bytesRead < 0) {
+        throw new EOFException(FSExceptionMessages.EOF_IN_READ_FULLY);
+      }
+    }
+  }
+
+  /**
+   * Byte-array positioned read. Routes through {@link #read(long, 
ByteBuffer)} so that the
+   * seek-read-restore sequence is covered by the same monitor as all other 
cursor-moving operations.
+   * Guards against null buffer (IAE) and negative position (EOFException) 
before touching ByteBuffer,
+   * aligning with the {@link org.apache.hadoop.fs.PositionedReadable} 
contract.
+   */
+  @Override
+  public synchronized int read(long position, byte[] buffer, int offset, int 
len) throws IOException {
+    if (buffer == null) {
+      throw new IllegalArgumentException("Null buffer");
+    }
+    if (position < 0) {
+      throw new EOFException("position is negative: " + position);
+    }
+    return read(position, ByteBuffer.wrap(buffer, offset, len));
+  }
+
+  @Override
+  public synchronized void readFully(long position, byte[] buffer, int offset, 
int len) throws IOException {
+    if (buffer == null) {
+      throw new IllegalArgumentException("Null buffer");
+    }
+    if (position < 0) {
+      throw new EOFException("position is negative: " + position);
+    }
+    readFully(position, ByteBuffer.wrap(buffer, offset, len));
+  }
+
+  @Override
+  public synchronized void readFully(long position, byte[] buffer) throws 
IOException {
+    readFully(position, buffer, 0, buffer.length);
+  }
+
   /**
    * Get number of bytes to read from the current stream based on the length
    * to be read, number of bytes remaining in the stream and the Crypto buffer
diff --git 
a/hadoop-ozone/client/src/test/java/org/apache/hadoop/ozone/client/io/TestOzoneCryptoInputStream.java
 
b/hadoop-ozone/client/src/test/java/org/apache/hadoop/ozone/client/io/TestOzoneCryptoInputStream.java
new file mode 100644
index 00000000000..d154479d8b2
--- /dev/null
+++ 
b/hadoop-ozone/client/src/test/java/org/apache/hadoop/ozone/client/io/TestOzoneCryptoInputStream.java
@@ -0,0 +1,504 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hadoop.ozone.client.io;
+
+import static 
org.apache.hadoop.hdds.scm.storage.PositionedReadTestHelper.BUFFER_SIZE;
+import static 
org.apache.hadoop.hdds.scm.storage.PositionedReadTestHelper.ITERATIONS;
+import static 
org.apache.hadoop.hdds.scm.storage.PositionedReadTestHelper.SOURCE_SIZE;
+import static 
org.apache.hadoop.hdds.scm.storage.PositionedReadTestHelper.THREAD_COUNT;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.ByteBuffer;
+import java.nio.ReadOnlyBufferException;
+import java.security.SecureRandom;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import org.apache.commons.lang3.RandomUtils;
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.crypto.CryptoCodec;
+import org.apache.hadoop.crypto.Encryptor;
+import org.apache.hadoop.fs.PositionedReadable;
+import org.apache.hadoop.fs.Seekable;
+import org.apache.hadoop.hdds.scm.storage.PositionedReadTestHelper;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+
+/**
+ * Unit tests for {@link OzoneCryptoInputStream}.
+ */
+public class TestOzoneCryptoInputStream {
+
+  private static final String KEY_NAME = "testKey";
+  private static final int PART_INDEX = 0;
+  // AES-128 key and IV
+  private static final int KEY_LEN = 16;
+  private static final int IV_LEN = 16;
+
+  private static CryptoCodec codec;
+  private static byte[] key;
+  private static byte[] iv;
+
+  @BeforeAll
+  static void setup() throws Exception {
+    Configuration conf = new Configuration();
+    // Force JCE backend to avoid native library dependency in tests
+    conf.set("hadoop.security.crypto.codec.classes.AES/CTR/NoPadding",
+        "org.apache.hadoop.crypto.JceAesCtrCryptoCodec");
+    codec = CryptoCodec.getInstance(conf);
+    key = new byte[KEY_LEN];
+    iv = new byte[IV_LEN];
+    new SecureRandom().nextBytes(key);
+    new SecureRandom().nextBytes(iv);
+  }
+
+  @Test
+  void testPositionedReadAtStart() throws Exception {
+    byte[] plaintext = RandomUtils.secure().randomBytes(64 * 1024);
+    try (OzoneCryptoInputStream s = buildStream(plaintext)) {
+      ByteBuffer buf = ByteBuffer.allocate(1024);
+      int n = s.read(0, buf);
+      buf.flip();
+      assertArrayEquals(Arrays.copyOf(plaintext, n), toArray(buf));
+    }
+  }
+
+  @Test
+  void testPositionedReadInMiddle() throws Exception {
+    byte[] plaintext = RandomUtils.secure().randomBytes(64 * 1024);
+    try (OzoneCryptoInputStream s = buildStream(plaintext)) {
+      int offset = 12345;
+      ByteBuffer buf = ByteBuffer.allocate(2048);
+      int n = s.read(offset, buf);
+      buf.flip();
+      assertArrayEquals(Arrays.copyOfRange(plaintext, offset, offset + n), 
toArray(buf));
+    }
+  }
+
+  @Test
+  void testPositionedReadCrossesBufferBoundary() throws Exception {
+    // The crypto buffer is 8 KB by default; read crossing that boundary 
exercises
+    // the position/length adjustment logic in 
OzoneCryptoInputStream.read(byte[], int, int).
+    byte[] plaintext = RandomUtils.secure().randomBytes(32 * 1024);
+    try (OzoneCryptoInputStream s = buildStream(plaintext)) {
+      // Start 100 bytes before the 8 KB boundary
+      int offset = 8 * 1024 - 100;
+      ByteBuffer buf = ByteBuffer.allocate(1024);
+      int n = s.read(offset, buf);
+      buf.flip();
+      assertArrayEquals(Arrays.copyOfRange(plaintext, offset, offset + n), 
toArray(buf));
+    }
+  }
+
+  @Test
+  void testPositionedReadDoesNotMoveSequentialCursor() throws Exception {
+    byte[] plaintext = RandomUtils.secure().randomBytes(16 * 1024);
+    try (OzoneCryptoInputStream s = buildStream(plaintext)) {
+      // Advance the sequential cursor
+      s.seek(500);
+      assertEquals(500, s.getPos());
+
+      // Positioned read at a different offset
+      ByteBuffer buf = ByteBuffer.allocate(256);
+      s.read(8000, buf);
+
+      // Sequential cursor must be restored
+      assertEquals(500, s.getPos());
+    }
+  }
+
+  @Test
+  void testPositionedReadAtEof() throws Exception {
+    byte[] plaintext = RandomUtils.secure().randomBytes(1024);
+    try (OzoneCryptoInputStream s = buildStream(plaintext)) {
+      ByteBuffer buf = ByteBuffer.allocate(64);
+      assertEquals(-1, s.read(plaintext.length, buf),
+          "read at position == length should return EOF");
+      assertEquals(-1, s.read(plaintext.length + 1, buf),
+          "read beyond length should return EOF");
+    }
+  }
+
+  @Test
+  void testPositionedReadEmptyBuffer() throws Exception {
+    byte[] plaintext = RandomUtils.secure().randomBytes(1024);
+    try (OzoneCryptoInputStream s = buildStream(plaintext)) {
+      assertEquals(0, s.read(0, ByteBuffer.allocate(0)));
+    }
+  }
+
+  @Test
+  void testReadFullyBasic() throws Exception {
+    byte[] plaintext = RandomUtils.secure().randomBytes(64 * 1024);
+    try (OzoneCryptoInputStream s = buildStream(plaintext)) {
+      int offset = 1000;
+      int len = 8192;
+      ByteBuffer buf = ByteBuffer.allocate(len);
+      s.readFully(offset, buf);
+      buf.flip();
+      assertArrayEquals(Arrays.copyOfRange(plaintext, offset, offset + len), 
toArray(buf));
+    }
+  }
+
+  @Test
+  void testReadFullyDoesNotMoveSequentialCursor() throws Exception {
+    byte[] plaintext = RandomUtils.secure().randomBytes(32 * 1024);
+    try (OzoneCryptoInputStream s = buildStream(plaintext)) {
+      s.seek(1234);
+      ByteBuffer buf = ByteBuffer.allocate(512);
+      s.readFully(16000, buf);
+      assertEquals(1234, s.getPos(), "sequential cursor must be restored after 
readFully");
+    }
+  }
+
+  @Test
+  void testByteArrayReadNullBufferThrowsIAE() throws Exception {
+    byte[] plaintext = RandomUtils.secure().randomBytes(1024);
+    try (OzoneCryptoInputStream s = buildStream(plaintext)) {
+      // Aligns with AbstractContractSeekTest.testReadNullBuffer: null must 
throw IAE, not NPE.
+      assertThrows(IllegalArgumentException.class, () -> s.read(0, (byte[]) 
null, 0, 16));
+    }
+  }
+
+  @Test
+  void testByteArrayReadNegativePositionThrowsEOF() throws Exception {
+    byte[] plaintext = RandomUtils.secure().randomBytes(1024);
+    try (OzoneCryptoInputStream s = buildStream(plaintext)) {
+      // Aligns with AbstractContractSeekTest.testReadSmallFile: negative 
position must throw.
+      assertThrows(EOFException.class, () -> s.read(-1, new byte[16], 0, 16));
+    }
+  }
+
+  @Test
+  void testByteArrayReadFullyNullBufferThrowsIAE() throws Exception {
+    byte[] plaintext = RandomUtils.secure().randomBytes(1024);
+    try (OzoneCryptoInputStream s = buildStream(plaintext)) {
+      assertThrows(IllegalArgumentException.class,
+          () -> s.readFully(0, (byte[]) null, 0, 16));
+    }
+  }
+
+  @Test
+  void testByteArrayReadFullyNegativePositionThrowsEOF() throws Exception {
+    byte[] plaintext = RandomUtils.secure().randomBytes(1024);
+    try (OzoneCryptoInputStream s = buildStream(plaintext)) {
+      // Aligns with AbstractContractSeekTest.testReadFullySmallFile: 
readFully must throw at invalid position.
+      assertThrows(EOFException.class, () -> s.readFully(-1, new byte[16], 0, 
16));
+    }
+  }
+
+  @Test
+  void testByteArrayReadAtEofReturnsMinusOne() throws Exception {
+    byte[] plaintext = RandomUtils.secure().randomBytes(1024);
+    try (OzoneCryptoInputStream s = buildStream(plaintext)) {
+      // Position exactly at EOF must return -1, not throw.
+      assertEquals(-1, s.read(plaintext.length, new byte[16], 0, 16));
+    }
+  }
+
+  @Test
+  void testByteBufferReadFullyThrowsEofWhenStreamTooShort() throws Exception {
+    byte[] plaintext = RandomUtils.secure().randomBytes(100);
+    try (OzoneCryptoInputStream s = buildStream(plaintext)) {
+      // Request 200 bytes starting at offset 50: 50 + 200 > 100 → EOFException
+      ByteBuffer buf = ByteBuffer.allocate(200);
+      assertThrows(EOFException.class, () -> s.readFully(50, buf));
+    }
+  }
+
+  @Test
+  void testByteArrayReadFullyThrowsEofWhenStreamTooShort() throws Exception {
+    byte[] plaintext = RandomUtils.secure().randomBytes(100);
+    try (OzoneCryptoInputStream s = buildStream(plaintext)) {
+      // Aligns with AbstractContractSeekTest.testReadFullySmallFile: partial 
buffer must throw.
+      byte[] buf = new byte[200];
+      assertThrows(EOFException.class, () -> s.readFully(50, buf, 0, 
buf.length));
+    }
+  }
+
+  @Test
+  void testPositionedReadRejectsReadOnlyBuffer() throws Exception {
+    byte[] plaintext = RandomUtils.secure().randomBytes(4 * 1024);
+    try (OzoneCryptoInputStream s = buildStream(plaintext)) {
+      ByteBuffer readOnly = ByteBuffer.wrap(new byte[256]).asReadOnlyBuffer();
+      assertThrows(ReadOnlyBufferException.class, () -> s.read(0, readOnly));
+    }
+  }
+
+  @Test
+  void testSequentialReadAfterReadOnlyRejection() throws Exception {
+    // A ReadOnlyBufferException on read(long, ByteBuffer) must leave the 
stream in
+    // a usable state: adjustment fields reset, cursor restored, next 
sequential read
+    // returns correct data.
+    byte[] plaintext = RandomUtils.secure().randomBytes(8 * 1024);
+    try (OzoneCryptoInputStream s = buildStream(plaintext)) {
+      ByteBuffer readOnly = ByteBuffer.allocate(256).asReadOnlyBuffer();
+      // Seek to a non-boundary offset so adjustReadPosition sets 
readPositionAdjustedBy.
+      s.seek(100);
+      assertThrows(ReadOnlyBufferException.class, () -> s.read(100, readOnly));
+      // Cursor must be restored.
+      assertEquals(100, s.getPos());
+      // Adjustment fields must be reset — sequential read must not throw.
+      byte[] buf = new byte[256];
+      assertDoesNotThrow(() -> s.read(buf, 0, buf.length));
+      assertArrayEquals(Arrays.copyOfRange(plaintext, 100, 356), buf);
+    }
+  }
+
+  @Test
+  void testByteArrayPositionedRead() throws Exception {
+    byte[] plaintext = RandomUtils.secure().randomBytes(32 * 1024);
+    try (OzoneCryptoInputStream s = buildStream(plaintext)) {
+      int offset = 5000;
+      byte[] buf = new byte[2048];
+      int n = s.read(offset, buf, 0, buf.length);
+      assertArrayEquals(Arrays.copyOfRange(plaintext, offset, offset + n),
+          Arrays.copyOf(buf, n));
+    }
+  }
+
+  @Test
+  void testByteArrayReadFully() throws Exception {
+    byte[] plaintext = RandomUtils.secure().randomBytes(32 * 1024);
+    try (OzoneCryptoInputStream s = buildStream(plaintext)) {
+      int offset = 3000;
+      byte[] buf = new byte[4096];
+      s.readFully(offset, buf, 0, buf.length);
+      assertArrayEquals(Arrays.copyOfRange(plaintext, offset, offset + 
buf.length), buf);
+    }
+  }
+
+  @Test
+  void testByteArrayReadFullyShortForm() throws Exception {
+    byte[] plaintext = RandomUtils.secure().randomBytes(32 * 1024);
+    try (OzoneCryptoInputStream s = buildStream(plaintext)) {
+      int offset = 1024;
+      byte[] buf = new byte[512];
+      s.readFully(offset, buf);
+      assertArrayEquals(Arrays.copyOfRange(plaintext, offset, offset + 
buf.length), buf);
+    }
+  }
+
+  @Test
+  void testByteArrayReadFullyDoesNotMoveSequentialCursor() throws Exception {
+    byte[] plaintext = RandomUtils.secure().randomBytes(16 * 1024);
+    try (OzoneCryptoInputStream s = buildStream(plaintext)) {
+      s.seek(777);
+      s.readFully(8000, new byte[256]);
+      assertEquals(777, s.getPos(), "sequential cursor must be restored after 
readFully(byte[])");
+    }
+  }
+
+  @Test
+  @Timeout(value = 30)
+  void testConcurrentSkipAndPositionedRead() throws Exception {
+    // Verifies that skip() is serialized on the same monitor as positioned 
reads.
+    // Half the threads continuously seek-then-skip (cursor-moving sequential 
ops).
+    // The other half do positioned readFully and assert correctness. If 
skip() could
+    // slip between the positioned read's internal seek and read, the read 
would
+    // consume data from the wrong offset and assertArrayEquals would fail.
+    byte[] plaintext = RandomUtils.secure().randomBytes(SOURCE_SIZE);
+    try (OzoneCryptoInputStream s = buildStream(plaintext)) {
+      ExecutorService pool = Executors.newFixedThreadPool(THREAD_COUNT);
+      try {
+        List<Future<?>> futures = new ArrayList<>();
+
+        // Cursor-moving workers: seek back to 0, then skip a large stride.
+        for (int i = 0; i < THREAD_COUNT / 2; i++) {
+          futures.add(pool.submit((Callable<Void>) () -> {
+            for (int j = 0; j < ITERATIONS; j++) {
+              s.seek(0);
+              s.skip(SOURCE_SIZE / 4);
+            }
+            return null;
+          }));
+        }
+
+        // Positioned-read workers: read at fixed offsets and verify data.
+        for (int i = THREAD_COUNT / 2; i < THREAD_COUNT; i++) {
+          final int t = i;
+          futures.add(pool.submit((Callable<Void>) () -> {
+            for (int j = 0; j < ITERATIONS; j++) {
+              int offset = (t * 1000 + j * 17) % (SOURCE_SIZE - BUFFER_SIZE);
+              byte[] buf = new byte[BUFFER_SIZE];
+              s.readFully(offset, buf);
+              assertArrayEquals(
+                  Arrays.copyOfRange(plaintext, offset, offset + BUFFER_SIZE),
+                  buf, "thread " + t + " offset " + offset);
+            }
+            return null;
+          }));
+        }
+
+        for (Future<?> f : futures) {
+          f.get(1, TimeUnit.MINUTES);
+        }
+      } finally {
+        pool.shutdownNow();
+      }
+    }
+  }
+
+  @Test
+  @Timeout(value = 30)
+  void testConcurrentPositionedReadsByteBuffer() throws Exception {
+    byte[] plaintext = RandomUtils.secure().randomBytes(SOURCE_SIZE);
+    try (OzoneCryptoInputStream s = buildStream(plaintext)) {
+      PositionedReadTestHelper.runConcurrentPositionedReads(plaintext,
+          (offset, buf) -> s.readFully(offset, buf));
+    }
+  }
+
+  @Test
+  @Timeout(value = 30)
+  void testConcurrentPositionedReadsByteArray() throws Exception {
+    byte[] plaintext = RandomUtils.secure().randomBytes(SOURCE_SIZE);
+    try (OzoneCryptoInputStream s = buildStream(plaintext)) {
+      PositionedReadTestHelper.runConcurrentPositionedReads(plaintext, 
(offset, buf) -> {
+        byte[] arr = new byte[buf.remaining()];
+        s.readFully(offset, arr);
+        buf.put(arr);
+      });
+    }
+  }
+
+  @Test
+  @Timeout(value = 30)
+  void testConcurrentPositionedReadsMixedApi() throws Exception {
+    // Intermix ByteBuffer and byte-array callers on the same stream to verify
+    // both APIs share the same monitor and do not interleave.
+    byte[] plaintext = RandomUtils.secure().randomBytes(SOURCE_SIZE);
+    try (OzoneCryptoInputStream s = buildStream(plaintext)) {
+      PositionedReadTestHelper.runConcurrentPositionedReads(plaintext, 
(offset, buf) -> {
+        if ((offset & 1) == 0) {
+          s.readFully(offset, buf);
+        } else {
+          byte[] arr = new byte[buf.remaining()];
+          s.readFully(offset, arr);
+          buf.put(arr);
+        }
+      });
+    }
+  }
+
+  /**
+   * Encrypts {@code plaintext} with the shared key/IV, wraps it in a
+   * {@link SeekableByteArrayInputStream}, and returns a ready-to-use
+   * {@link OzoneCryptoInputStream}.
+   */
+  private static OzoneCryptoInputStream buildStream(byte[] plaintext) throws 
Exception {
+    Encryptor enc = codec.createEncryptor();
+    enc.init(key, iv);
+    ByteBuffer plain = ByteBuffer.wrap(plaintext);
+    ByteBuffer cipher = ByteBuffer.allocate(plaintext.length);
+    enc.encrypt(plain, cipher);
+
+    SeekableByteArrayInputStream raw = new 
SeekableByteArrayInputStream(cipher.array());
+    LengthInputStream lin = new LengthInputStream(raw, plaintext.length);
+    return new OzoneCryptoInputStream(lin, codec, key, iv, KEY_NAME, 
PART_INDEX);
+  }
+
+  private static byte[] toArray(ByteBuffer buf) {
+    byte[] out = new byte[buf.remaining()];
+    buf.get(out);
+    return out;
+  }
+
+  /**
+   * Seekable, in-memory {@link InputStream} backed by a byte array.
+   * Used to feed ciphertext into {@link OzoneCryptoInputStream} in tests.
+   */
+  private static final class SeekableByteArrayInputStream extends InputStream
+      implements Seekable, PositionedReadable {
+
+    private final byte[] data;
+    private volatile int pos;
+
+    SeekableByteArrayInputStream(byte[] data) {
+      this.data = data;
+    }
+
+    @Override
+    public synchronized int read() throws IOException {
+      return pos < data.length ? (data[pos++] & 0xFF) : -1;
+    }
+
+    @Override
+    public synchronized int read(byte[] b, int off, int len) throws 
IOException {
+      if (pos >= data.length) {
+        return -1;
+      }
+      int n = Math.min(len, data.length - pos);
+      System.arraycopy(data, pos, b, off, n);
+      pos += n;
+      return n;
+    }
+
+    @Override
+    public synchronized void seek(long newPos) throws IOException {
+      if (newPos < 0 || newPos > data.length) {
+        throw new IOException("Invalid seek position: " + newPos);
+      }
+      pos = (int) newPos;
+    }
+
+    @Override
+    public synchronized long getPos() {
+      return pos;
+    }
+
+    @Override
+    public boolean seekToNewSource(long targetPos) {
+      return false;
+    }
+
+    @Override
+    public int read(long position, byte[] buffer, int offset, int length) 
throws IOException {
+      if (position >= data.length) {
+        return -1;
+      }
+      int n = Math.min(length, data.length - (int) position);
+      System.arraycopy(data, (int) position, buffer, offset, n);
+      return n;
+    }
+
+    @Override
+    public void readFully(long position, byte[] buffer, int offset, int 
length) throws IOException {
+      if (read(position, buffer, offset, length) < length) {
+        throw new EOFException("SeekableByteArrayInputStream: EOF at " + 
position);
+      }
+    }
+
+    @Override
+    public void readFully(long position, byte[] buffer) throws IOException {
+      readFully(position, buffer, 0, buffer.length);
+    }
+  }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to