Repository: googleapis/java-storage
Pull request #1474 (2022-06-27 18:59:20)

chore: refactor GrpcStorageImpl#read* methods to use more specific channels
Update readAllBytes and downloadTo methods to use unbuffered read channel, instead of reader

Update *ReadableByteChannelSessions to take a ReadObjectRequest instead of Object

Update Hasher#validate to be lazy with regard to the ByteBuffer, this ensures no new byte buffer is created if it isn't needed in the case of noop.

Add StorageArbitraries.Objects#name()
Move StorageArbitraries.bucketName() -> StorageArbitraries.Buckets#name()
Add StorageArbitraries.alnum() and cleanup usages in Buckets.name()



Patch:
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/GapicDownloadSessionBuilder.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/GapicDownloadSessionBuilder.java
index 25e592e2..d371c5e1 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/GapicDownloadSessionBuilder.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/GapicDownloadSessionBuilder.java
@@ -76,7 +76,8 @@ final class GapicDownloadSessionBuilder {
       return new UnbufferedReadableByteChannelSessionBuilder(getF(read, hasher));
     }
 
-    private static BiFunction<Object, SettableApiFuture<Object>, UnbufferedReadableByteChannel>
+    private static BiFunction<
+            ReadObjectRequest, SettableApiFuture<Object>, UnbufferedReadableByteChannel>
         getF(ServerStreamingCallable<ReadObjectRequest, ReadObjectResponse> read, Hasher hasher) {
       return (object, resultFuture) ->
           new GapicUnbufferedReadableByteChannel(resultFuture, read, object, hasher);
@@ -84,46 +85,52 @@ final class GapicDownloadSessionBuilder {
 
     public static final class BufferedReadableByteChannelSessionBuilder {
 
-      private BiFunction<Object, SettableApiFuture<Object>, BufferedReadableByteChannel> f;
-      private Object obj;
+      private BiFunction<ReadObjectRequest, SettableApiFuture<Object>, BufferedReadableByteChannel>
+          f;
+      private ReadObjectRequest request;
 
       private BufferedReadableByteChannelSessionBuilder(
           BufferHandle buffer,
-          BiFunction<Object, SettableApiFuture<Object>, UnbufferedReadableByteChannel> f) {
+          BiFunction<ReadObjectRequest, SettableApiFuture<Object>, UnbufferedReadableByteChannel>
+              f) {
         this.f = f.andThen(c -> new DefaultBufferedReadableByteChannel(buffer, c));
       }
 
-      public BufferedReadableByteChannelSessionBuilder setObject(Object obj) {
-        this.obj = requireNonNull(obj, "obj must be non null");
+      public BufferedReadableByteChannelSessionBuilder setReadObjectRequest(
+          ReadObjectRequest request) {
+        this.request = requireNonNull(request, "request must be non null");
         return this;
       }
 
       public BufferedReadableByteChannelSession<Object> build() {
         return new ChannelSession.BufferedReadSession<>(
-            ApiFutures.immediateFuture(obj),
+            ApiFutures.immediateFuture(request),
             f.andThen(StorageByteChannels.readable()::createSynchronized));
       }
     }
 
     public static final class UnbufferedReadableByteChannelSessionBuilder {
 
-      // TODO: object -> ReadObjectRequest
-      private BiFunction<Object, SettableApiFuture<Object>, UnbufferedReadableByteChannel> f;
-      private Object obj;
+      private BiFunction<
+              ReadObjectRequest, SettableApiFuture<Object>, UnbufferedReadableByteChannel>
+          f;
+      private ReadObjectRequest request;
 
       private UnbufferedReadableByteChannelSessionBuilder(
-          BiFunction<Object, SettableApiFuture<Object>, UnbufferedReadableByteChannel> f) {
+          BiFunction<ReadObjectRequest, SettableApiFuture<Object>, UnbufferedReadableByteChannel>
+              f) {
         this.f = f;
       }
 
-      public UnbufferedReadableByteChannelSessionBuilder setObject(Object obj) {
-        this.obj = requireNonNull(obj, "obj must be non null");
+      public UnbufferedReadableByteChannelSessionBuilder setReadObjectRequest(
+          ReadObjectRequest request) {
+        this.request = requireNonNull(request, "request must be non null");
         return this;
       }
 
       public UnbufferedReadableByteChannelSession<Object> build() {
         return new ChannelSession.UnbufferedReadSession<>(
-            ApiFutures.immediateFuture(obj),
+            ApiFutures.immediateFuture(request),
             f.andThen(StorageByteChannels.readable()::createSynchronized));
       }
     }
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/GapicUnbufferedReadableByteChannel.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/GapicUnbufferedReadableByteChannel.java
index 4d9bc30c..4899a0d1 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/GapicUnbufferedReadableByteChannel.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/GapicUnbufferedReadableByteChannel.java
@@ -16,6 +16,7 @@
 
 package com.google.cloud.storage;
 
+import static com.google.cloud.storage.StorageV2ProtoUtils.seekReadObjectRequest;
 import static com.google.common.base.Preconditions.checkArgument;
 
 import com.google.api.client.http.HttpStatusCodes;
@@ -59,17 +60,11 @@ final class GapicUnbufferedReadableByteChannel
   GapicUnbufferedReadableByteChannel(
       SettableApiFuture<Object> result,
       ServerStreamingCallable<ReadObjectRequest, ReadObjectResponse> read,
-      Object obj,
+      ReadObjectRequest req,
       Hasher hasher) {
     this.result = result;
     this.read = read;
-    this.req =
-        ReadObjectRequest.newBuilder()
-            .setBucket(obj.getBucket())
-            .setObject(obj.getName())
-            .setGeneration(obj.getGeneration())
-            .setReadOffset(blobOffset)
-            .build();
+    this.req = req;
     this.hasher = hasher;
     this.iter = new LazyServerStreamIterator();
   }
@@ -130,7 +125,7 @@ final class GapicUnbufferedReadableByteChannel
         ByteBuffer content = checksummedData.getContent().asReadOnlyByteBuffer();
         Crc32cLengthUnknown expected = Crc32cValue.of(checksummedData.getCrc32C());
         try {
-          hasher.validate(expected, content.duplicate());
+          hasher.validate(expected, content::duplicate);
         } catch (IOException e) {
           close();
           throw e;
@@ -240,21 +235,7 @@ final class GapicUnbufferedReadableByteChannel
         synchronized (this) {
           if (!streamInitialized) {
             if (serverStream == null) {
-              ReadObjectRequest request = req;
-
-              boolean setOffset = blobOffset > 0 && blobOffset != req.getReadOffset();
-              boolean setLimit = blobLimit < Long.MAX_VALUE && blobLimit != req.getReadLimit();
-              if (setOffset || setLimit) {
-                ReadObjectRequest.Builder b = request.toBuilder();
-                if (setOffset) {
-                  b.setReadOffset(blobOffset);
-                }
-                if (setLimit) {
-                  b.setReadLimit(blobLimit);
-                }
-                request = b.build();
-              }
-
+              ReadObjectRequest request = seekReadObjectRequest(req, blobOffset, blobLimit);
               serverStream = read.call(request);
             }
             responseIterator = serverStream.iterator();
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcBlobReadChannel.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcBlobReadChannel.java
index d168305e..d28f22c2 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcBlobReadChannel.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcBlobReadChannel.java
@@ -16,14 +16,16 @@
 
 package com.google.cloud.storage;
 
+import static com.google.cloud.storage.StorageV2ProtoUtils.seekReadObjectRequest;
 import static com.google.cloud.storage.Utils.todo;
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
 
 import com.google.api.core.ApiFuture;
 import com.google.api.gax.rpc.ServerStreamingCallable;
 import com.google.cloud.ReadChannel;
 import com.google.cloud.RestorableState;
 import com.google.cloud.storage.BufferedReadableByteChannelSession.BufferedReadableByteChannel;
-import com.google.common.base.Preconditions;
 import com.google.common.base.Suppliers;
 import com.google.storage.v2.Object;
 import com.google.storage.v2.ReadObjectRequest;
@@ -36,26 +38,31 @@ final class GrpcBlobReadChannel implements ReadChannel {
 
   private final LazyReadChannel lazyReadChannel;
 
+  private Long position;
+  private Long limit;
   private int chunkSize = 16 * 1024 * 1024;
 
   GrpcBlobReadChannel(
-      ServerStreamingCallable<ReadObjectRequest, ReadObjectResponse> read, Supplier<Object> start) {
+      ServerStreamingCallable<ReadObjectRequest, ReadObjectResponse> read,
+      ReadObjectRequest request) {
     this.lazyReadChannel =
         new LazyReadChannel(
             Suppliers.memoize(
-                () ->
-                    ResumableMedia.gapic()
-                        .read()
-                        .byteChannel(read)
-                        .setHasher(Hasher.noop())
-                        .buffered(ByteBuffer.allocate(chunkSize))
-                        .setObject(start.get())
-                        .build()));
+                () -> {
+                  ReadObjectRequest req = seekReadObjectRequest(request, position, limit);
+                  return ResumableMedia.gapic()
+                      .read()
+                      .byteChannel(read)
+                      .setHasher(Hasher.noop())
+                      .buffered(ByteBuffer.allocate(chunkSize))
+                      .setReadObjectRequest(req)
+                      .build();
+                }));
   }
 
   @Override
   public void setChunkSize(int chunkSize) {
-    Preconditions.checkState(!isOpen(), "Unable to change chunkSize after write");
+    checkState(!isOpen(), "Unable to change chunkSize after read");
     this.chunkSize = chunkSize;
   }
 
@@ -78,7 +85,9 @@ final class GrpcBlobReadChannel implements ReadChannel {
 
   @Override
   public void seek(long position) throws IOException {
-    todo();
+    checkArgument(position >= 0, "position must be >= 0");
+    checkState(!isOpen(), "Unable to change position after read");
+    this.position = position;
   }
 
   @Override
@@ -88,12 +97,15 @@ final class GrpcBlobReadChannel implements ReadChannel {
 
   @Override
   public ReadChannel limit(long limit) {
-    return todo();
+    checkArgument(limit >= 0, "limit must be >= 0");
+    checkState(!isOpen(), "Unable to change limit after read");
+    this.limit = limit;
+    return this;
   }
 
   @Override
   public long limit() {
-    return todo();
+    return limit != null ? limit : Long.MAX_VALUE;
   }
 
   @Override
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageImpl.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageImpl.java
index 6941412d..ba7e3e89 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageImpl.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/GrpcStorageImpl.java
@@ -30,7 +30,6 @@ import com.google.api.gax.rpc.ApiExceptions;
 import com.google.api.gax.rpc.UnaryCallable;
 import com.google.cloud.BaseService;
 import com.google.cloud.Policy;
-import com.google.cloud.ReadChannel;
 import com.google.cloud.WriteChannel;
 import com.google.cloud.storage.Acl.Entity;
 import com.google.cloud.storage.Conversions.Decoder;
@@ -38,11 +37,16 @@ import com.google.cloud.storage.HmacKey.HmacKeyMetadata;
 import com.google.cloud.storage.HmacKey.HmacKeyState;
 import com.google.cloud.storage.PostPolicyV4.PostConditionsV4;
 import com.google.cloud.storage.PostPolicyV4.PostFieldsV4;
+import com.google.cloud.storage.UnbufferedReadableByteChannelSession.UnbufferedReadableByteChannel;
 import com.google.cloud.storage.spi.v1.StorageRpc;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.hash.HashCode;
+import com.google.common.hash.Hashing;
 import com.google.common.io.ByteStreams;
 import com.google.common.util.concurrent.MoreExecutors;
+import com.google.protobuf.ByteString;
 import com.google.storage.v2.BucketName;
+import com.google.storage.v2.CommonObjectRequestParams;
 import com.google.storage.v2.CreateBucketRequest;
 import com.google.storage.v2.DeleteBucketRequest;
 import com.google.storage.v2.DeleteHmacKeyRequest;
@@ -54,6 +58,7 @@ import com.google.storage.v2.ListBucketsRequest;
 import com.google.storage.v2.ListHmacKeysRequest;
 import com.google.storage.v2.ListObjectsRequest;
 import com.google.storage.v2.Object;
+import com.google.storage.v2.ReadObjectRequest;
 import com.google.storage.v2.StartResumableWriteRequest;
 import com.google.storage.v2.StorageClient.ListBucketsPage;
 import com.google.storage.v2.StorageClient.ListBucketsPagedResponse;
@@ -80,6 +85,7 @@ import java.nio.file.OpenOption;
 import java.nio.file.Path;
 import java.nio.file.StandardOpenOption;
 import java.util.Arrays;
+import java.util.Base64;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
@@ -423,8 +429,20 @@ final class GrpcStorageImpl extends BaseService<StorageOptions> implements Stora
 
   @Override
   public byte[] readAllBytes(BlobId blob, BlobSourceOption... options) {
+
+    final Map<StorageRpc.Option, ?> optionsMap = StorageImpl.optionMap(options);
+
+    ReadObjectRequest readObjectRequest = getReadObjectRequest(blob, optionsMap);
+    UnbufferedReadableByteChannelSession<Object> session =
+        ResumableMedia.gapic()
+            .read()
+            .byteChannel(grpcStorageStub.readObjectCallable())
+            .unbuffered()
+            .setReadObjectRequest(readObjectRequest)
+            .build();
+
     ByteArrayOutputStream baos = new ByteArrayOutputStream();
-    try (ReadChannel r = reader(blob, options);
+    try (UnbufferedReadableByteChannel r = session.open();
         WritableByteChannel w = Channels.newChannel(baos)) {
       ByteStreams.copy(r, w);
     } catch (IOException e) {
@@ -445,27 +463,56 @@ final class GrpcStorageImpl extends BaseService<StorageOptions> implements Stora
 
   @Override
   public GrpcBlobReadChannel reader(BlobId blob, BlobSourceOption... options) {
-    Object pb = codecs.blobId().encode(blob);
-    return new GrpcBlobReadChannel(grpcStorageStub.readObjectCallable(), () -> pb);
+    final Map<StorageRpc.Option, ?> optionsMap = StorageImpl.optionMap(options);
+    ReadObjectRequest request = getReadObjectRequest(blob, optionsMap);
+    return new GrpcBlobReadChannel(grpcStorageStub.readObjectCallable(), request);
   }
 
   @Override
   public void downloadTo(BlobId blob, Path path, BlobSourceOption... options) {
-    try (WritableByteChannel out = Files.newByteChannel(path, WRITE_OPS);
-        GrpcBlobReadChannel in = reader(blob, options)) {
-      ByteStreams.copy(in, out);
+
+    final Map<StorageRpc.Option, ?> optionsMap = StorageImpl.optionMap(options);
+
+    // TODO: handle StorageRpc.Option.RETURN_RAW_INPUT_STREAM impacts
+
+    ReadObjectRequest readObjectRequest = getReadObjectRequest(blob, optionsMap);
+    UnbufferedReadableByteChannelSession<Object> session =
+        ResumableMedia.gapic()
+            .read()
+            .byteChannel(grpcStorageStub.readObjectCallable())
+            .unbuffered()
+            .setReadObjectRequest(readObjectRequest)
+            .build();
+
+    try (UnbufferedReadableByteChannel r = session.open();
+        WritableByteChannel w = Files.newByteChannel(path, WRITE_OPS)) {
+      ByteStreams.copy(r, w);
     } catch (IOException e) {
-      throw new StorageException(e);
+      throw StorageException.coalesce(e);
     }
   }
 
   @Override
   public void downloadTo(BlobId blob, OutputStream outputStream, BlobSourceOption... options) {
-    try (WritableByteChannel out = Channels.newChannel(outputStream);
-        GrpcBlobReadChannel in = reader(blob, options)) {
-      ByteStreams.copy(in, out);
+
+    final Map<StorageRpc.Option, ?> optionsMap = StorageImpl.optionMap(options);
+
+    // TODO: handle StorageRpc.Option.RETURN_RAW_INPUT_STREAM impacts
+
+    ReadObjectRequest readObjectRequest = getReadObjectRequest(blob, optionsMap);
+    UnbufferedReadableByteChannelSession<Object> session =
+        ResumableMedia.gapic()
+            .read()
+            .byteChannel(grpcStorageStub.readObjectCallable())
+            .unbuffered()
+            .setReadObjectRequest(readObjectRequest)
+            .build();
+
+    try (UnbufferedReadableByteChannel r = session.open();
+        WritableByteChannel w = Channels.newChannel(outputStream)) {
+      ByteStreams.copy(r, w);
     } catch (IOException e) {
-      throw new StorageException(e);
+      throw StorageException.coalesce(e);
     }
   }
 
@@ -936,4 +983,45 @@ final class GrpcStorageImpl extends BaseService<StorageOptions> implements Stora
         + Arrays.stream(args).map(Class::getName).collect(Collectors.joining(", "))
         + ")";
   }
+
+  private ReadObjectRequest getReadObjectRequest(
+      BlobId blob, Map<StorageRpc.Option, ?> optionsMap) {
+    Object object = codecs.blobId().encode(blob);
+
+    ReadObjectRequest.Builder builder =
+        ReadObjectRequest.newBuilder().setBucket(object.getBucket()).setObject(object.getName());
+
+    long generation = object.getGeneration();
+    if (generation > 0) {
+      builder.setGeneration(generation);
+    }
+    ifNonNull(
+        (Long) optionsMap.get(StorageRpc.Option.IF_METAGENERATION_MATCH),
+        builder::setIfMetagenerationMatch);
+    ifNonNull(
+        (Long) optionsMap.get(StorageRpc.Option.IF_METAGENERATION_NOT_MATCH),
+        builder::setIfMetagenerationNotMatch);
+    ifNonNull(
+        (Long) optionsMap.get(StorageRpc.Option.IF_GENERATION_MATCH),
+        builder::setIfGenerationMatch);
+    ifNonNull(
+        (Long) optionsMap.get(StorageRpc.Option.IF_GENERATION_NOT_MATCH),
+        builder::setIfGenerationNotMatch);
+    String userProject = (String) optionsMap.get(StorageRpc.Option.USER_PROJECT);
+    if (userProject != null) {
+      // TODO: user project
+      // ReadObjectRequest does not have a user_project field
+    }
+    String key = (String) optionsMap.get(StorageRpc.Option.CUSTOMER_SUPPLIED_KEY);
+    if (key != null) {
+      CommonObjectRequestParams.Builder paramsBuilder = CommonObjectRequestParams.newBuilder();
+      paramsBuilder.setEncryptionAlgorithm("AES256");
+      paramsBuilder.setEncryptionKeyBytes(ByteString.copyFromUtf8(key));
+      byte[] keyBytes = Base64.getDecoder().decode(key);
+      HashCode keySha256 = Hashing.sha256().hashBytes(keyBytes);
+      paramsBuilder.setEncryptionKeySha256Bytes(ByteString.copyFrom(keySha256.asBytes()));
+      builder.setCommonObjectRequestParams(paramsBuilder.build());
+    }
+    return builder.build();
+  }
 }
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/Hasher.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/Hasher.java
index ce01c9a8..2c617abd 100644
--- a/google-cloud-storage/src/main/java/com/google/cloud/storage/Hasher.java
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/Hasher.java
@@ -31,7 +31,7 @@ interface Hasher {
 
   Crc32cLengthKnown hash(ByteBuffer b);
 
-  void validate(Crc32cValue<?> expected, ByteBuffer b) throws IOException;
+  void validate(Crc32cValue<?> expected, Supplier<ByteBuffer> b) throws IOException;
 
   static Hasher noop() {
     return NoOpHasher.INSTANCE;
@@ -53,7 +53,7 @@ interface Hasher {
     }
 
     @Override
-    public void validate(Crc32cValue<?> expected, ByteBuffer b) {}
+    public void validate(Crc32cValue<?> expected, Supplier<ByteBuffer> b) {}
   }
 
   @Immutable
@@ -69,11 +69,11 @@ interface Hasher {
     }
 
     @Override
-    public void validate(Crc32cValue<?> expected, ByteBuffer b) throws IOException {
+    public void validate(Crc32cValue<?> expected, Supplier<ByteBuffer> b) throws IOException {
       Crc32cLengthKnown actual = hash(b);
       if (actual.getValue() != expected.getValue()) {
         throw new IOException(
-            String.format("Miss-match checksum value. expected %s actual %s", expected, actual));
+            String.format("Mismatch checksum value. Expected %s actual %s", expected, actual));
       }
     }
   }
diff --git a/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageV2ProtoUtils.java b/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageV2ProtoUtils.java
new file mode 100644
index 00000000..e1ee5ba3
--- /dev/null
+++ b/google-cloud-storage/src/main/java/com/google/cloud/storage/StorageV2ProtoUtils.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed 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 com.google.cloud.storage;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.protobuf.InvalidProtocolBufferException;
+import com.google.protobuf.MessageOrBuilder;
+import com.google.protobuf.util.JsonFormat;
+import com.google.protobuf.util.JsonFormat.Printer;
+import com.google.storage.v2.ReadObjectRequest;
+import org.checkerframework.checker.nullness.qual.NonNull;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+final class StorageV2ProtoUtils {
+
+  private static final String VALIDATION_TEMPLATE = "0 <= offset <= limit (0 <= %s <= %s)";
+
+  private static final Printer PROTO_PRINTER =
+      JsonFormat.printer().omittingInsignificantWhitespace().preservingProtoFieldNames();
+
+  private StorageV2ProtoUtils() {}
+
+  @NonNull
+  static ReadObjectRequest seekReadObjectRequest(
+      @NonNull ReadObjectRequest request, @Nullable Long offset, @Nullable Long limit) {
+    validate(offset, limit);
+    ReadObjectRequest req = request;
+
+    boolean setOffset = offset != null && (offset > 0 || offset != req.getReadOffset());
+    boolean setLimit = limit != null && (limit < Long.MAX_VALUE || limit != req.getReadLimit());
+    if (setOffset || setLimit) {
+      ReadObjectRequest.Builder b = req.toBuilder();
+      if (setOffset) {
+        b.setReadOffset(offset);
+      }
+      if (setLimit) {
+        b.setReadLimit(limit);
+      }
+      req = b.build();
+    }
+    return req;
+  }
+
+  private static void validate(@Nullable Long offset, @Nullable Long limit) {
+    boolean offsetNull = offset == null;
+    boolean limitNull = limit == null;
+
+    if (offsetNull && limitNull) {
+      return;
+    }
+
+    if (!offsetNull) {
+      checkArgument(0 <= offset, VALIDATION_TEMPLATE, offset, limit);
+    }
+
+    if (!limitNull) {
+      checkArgument(0 <= limit, VALIDATION_TEMPLATE, offset, limit);
+    }
+
+    if (!offsetNull && !limitNull) {
+      checkArgument(offset <= limit, VALIDATION_TEMPLATE, offset, limit);
+    }
+  }
+
+  @NonNull
+  static String fmtProto(@NonNull final MessageOrBuilder msg) {
+    try {
+      return PROTO_PRINTER.print(msg);
+    } catch (InvalidProtocolBufferException e) {
+      throw new RuntimeException(e);
+    }
+  }
+}


Test patch:
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageV2ProtoUtilsTest.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageV2ProtoUtilsTest.java
new file mode 100644
index 00000000..461efcaa
--- /dev/null
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/StorageV2ProtoUtilsTest.java
@@ -0,0 +1,142 @@
+/*
+ * Copyright 2022 Google LLC
+ *
+ * Licensed 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 com.google.cloud.storage;
+
+import static com.google.cloud.storage.StorageV2ProtoUtils.fmtProto;
+import static com.google.cloud.storage.StorageV2ProtoUtils.seekReadObjectRequest;
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import com.google.cloud.storage.jqwik.StorageArbitraries;
+import com.google.storage.v2.ReadObjectRequest;
+import net.jqwik.api.Arbitraries;
+import net.jqwik.api.Arbitrary;
+import net.jqwik.api.Combinators;
+import net.jqwik.api.Example;
+import net.jqwik.api.ForAll;
+import net.jqwik.api.Property;
+import net.jqwik.api.Provide;
+
+public final class StorageV2ProtoUtilsTest {
+
+  @Example
+  void validation_nullOffset_effectiveMin() {
+    seekReadObjectRequest(ReadObjectRequest.getDefaultInstance(), null, 0L);
+    seekReadObjectRequest(ReadObjectRequest.getDefaultInstance(), null, null);
+  }
+
+  @Example
+  void validation_nullLimit_effectiveMax() {
+    seekReadObjectRequest(ReadObjectRequest.getDefaultInstance(), 0L, null);
+    seekReadObjectRequest(ReadObjectRequest.getDefaultInstance(), null, null);
+  }
+
+  @Example
+  void validation_offset_lteq_limit() {
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> seekReadObjectRequest(ReadObjectRequest.getDefaultInstance(), 3L, 2L));
+    seekReadObjectRequest(ReadObjectRequest.getDefaultInstance(), 0L, 0L);
+    seekReadObjectRequest(ReadObjectRequest.getDefaultInstance(), 1L, 1L);
+  }
+
+  @Example
+  void validation_offset_gteq_0() {
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> seekReadObjectRequest(ReadObjectRequest.getDefaultInstance(), -1L, null));
+  }
+
+  @Example
+  void validation_limit_gteq_0() {
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> seekReadObjectRequest(ReadObjectRequest.getDefaultInstance(), null, -1L));
+  }
+
+  @Property(tries = 100_000)
+  void seek(@ForAll("seekCases") SeekCase srr) {
+    Long offset = srr.offset;
+    Long limit = srr.limit;
+
+    // I miss pattern matching...
+    if (offset == null && limit == null) {
+      ReadObjectRequest seek = seekReadObjectRequest(srr.req, offset, limit);
+      assertThat(seek).isSameInstanceAs(srr.req);
+    } else if (offset != null && limit == null) {
+      ReadObjectRequest seek = seekReadObjectRequest(srr.req, offset, limit);
+      assertThat(seek.getReadOffset()).isEqualTo(offset);
+    } else if (offset == null && limit != null) {
+      ReadObjectRequest seek = seekReadObjectRequest(srr.req, offset, limit);
+      assertThat(seek.getReadLimit()).isEqualTo(limit);
+    } else if (offset <= limit) {
+      ReadObjectRequest seek = seekReadObjectRequest(srr.req, offset, limit);
+      assertThat(seek.getReadOffset()).isEqualTo(offset);
+      assertThat(seek.getReadLimit()).isEqualTo(limit);
+    } else {
+      assertThrows(
+          IllegalArgumentException.class, () -> seekReadObjectRequest(srr.req, offset, limit));
+    }
+  }
+
+  @Provide("seekCases")
+  Arbitrary<SeekCase> arbitrarySeekCase() {
+    return Combinators.combine(
+            StorageArbitraries.objects().name(),
+            Arbitraries.longs().greaterOrEqual(0).injectNull(0.6),
+            Arbitraries.longs().greaterOrEqual(0).injectNull(0.6),
+            Arbitraries.longs().greaterOrEqual(0).injectNull(0.3),
+            Arbitraries.longs().greaterOrEqual(0).injectNull(0.3))
+        .as(SeekCase::of);
+  }
+
+  private static final class SeekCase {
+    private final ReadObjectRequest req;
+    private final Long offset;
+    private final Long limit;
+
+    public SeekCase(ReadObjectRequest req, Long offset, Long limit) {
+      this.req = req;
+      this.offset = offset;
+      this.limit = limit;
+    }
+
+    @Override
+    public String toString() {
+      return "SeekReadRequest{"
+          + "req="
+          + fmtProto(req)
+          + ", offset="
+          + offset
+          + ", limit="
+          + limit
+          + '}';
+    }
+
+    private static SeekCase of(
+        String name, Long embedOffset, Long embedLimit, Long offset, Long limit) {
+      ReadObjectRequest.Builder b = ReadObjectRequest.newBuilder().setObject(name);
+      if (embedOffset != null) {
+        b.setReadOffset(embedOffset);
+      }
+      if (embedLimit != null) {
+        b.setReadLimit(embedLimit);
+      }
+      return new SeekCase(b.build(), offset, limit);
+    }
+  }
+}
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/jqwik/BucketArbitraryProvider.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/jqwik/BucketArbitraryProvider.java
index c63d1f64..dcf5c942 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/jqwik/BucketArbitraryProvider.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/jqwik/BucketArbitraryProvider.java
@@ -37,8 +37,8 @@ public final class BucketArbitraryProvider implements ArbitraryProvider {
     Arbitrary<Bucket> as =
         Combinators.combine(
                 Combinators.combine(
-                        StorageArbitraries.bucketName(),
-                        StorageArbitraries.bucketName(),
+                        StorageArbitraries.buckets().name(),
+                        StorageArbitraries.buckets().name(),
                         StorageArbitraries.buckets().storageClass(),
                         StorageArbitraries.buckets().location(),
                         StorageArbitraries.buckets().locationType(),
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/jqwik/ObjectArbitraryProvider.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/jqwik/ObjectArbitraryProvider.java
index b8df4daa..e62e97ae 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/jqwik/ObjectArbitraryProvider.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/jqwik/ObjectArbitraryProvider.java
@@ -35,13 +35,12 @@ public final class ObjectArbitraryProvider implements ArbitraryProvider {
 
   @Override
   public Set<Arbitrary<?>> provideFor(TypeUsage targetType, SubtypeProvider subtypeProvider) {
-    Arbitrary<String> objectName = StorageArbitraries.randomString();
     Arbitrary<Integer> size = Arbitraries.integers().greaterOrEqual(0);
     Arbitrary<Object> objectArbitrary =
         Combinators.combine(
                 Combinators.combine(
-                        objectName,
-                        StorageArbitraries.bucketName(),
+                        StorageArbitraries.objects().name(),
+                        StorageArbitraries.buckets().name(),
                         StorageArbitraries.generation(),
                         StorageArbitraries.metageneration(),
                         StorageArbitraries.objects().storageClass(),
diff --git a/google-cloud-storage/src/test/java/com/google/cloud/storage/jqwik/StorageArbitraries.java b/google-cloud-storage/src/test/java/com/google/cloud/storage/jqwik/StorageArbitraries.java
index b666b4c9..40db1f38 100644
--- a/google-cloud-storage/src/test/java/com/google/cloud/storage/jqwik/StorageArbitraries.java
+++ b/google-cloud-storage/src/test/java/com/google/cloud/storage/jqwik/StorageArbitraries.java
@@ -16,6 +16,8 @@
 
 package com.google.cloud.storage.jqwik;
 
+import static com.google.common.base.Preconditions.checkArgument;
+
 import com.google.common.hash.Hashing;
 import com.google.common.io.BaseEncoding;
 import com.google.protobuf.ByteString;
@@ -39,6 +41,7 @@ import net.jqwik.api.Arbitraries;
 import net.jqwik.api.Arbitrary;
 import net.jqwik.api.Combinators;
 import net.jqwik.api.Tuple;
+import net.jqwik.api.arbitraries.CharacterArbitrary;
 import net.jqwik.api.arbitraries.ListArbitrary;
 import net.jqwik.api.providers.TypeUsage;
 import net.jqwik.time.api.DateTimes;
@@ -85,56 +88,24 @@ public final class StorageArbitraries {
     return Arbitraries.strings().all().ofMinLength(1).ofMaxLength(1024);
   }
 
-  public static Arbitrary<ProjectID> projectID() {
-    return Arbitraries.oneOf(
-        Combinators.combine(
-                // must start with a letter
-                Arbitraries.chars().range('a', 'z'),
-                // can only contain numbers, lowercase letters, and hyphens, and must be 6-30 chars
-                Arbitraries.strings()
-                    .withCharRange('a', 'z')
-                    .numeric()
-                    .withChars('-')
-                    .ofMinLength(4)
-                    .ofMaxLength(28),
-                // must not end with a hyphen
-                Arbitraries.chars().range('a', 'z').numeric())
-            .as(
-                (first, mid, last) -> {
-                  final StringBuilder sb = new StringBuilder();
-                  sb.append(first).append(mid).append(last);
-                  return new ProjectID(sb.toString());
-                }));
+  public static CharacterArbitrary alnum() {
+    return Arbitraries.chars().alpha().numeric();
   }
 
-  /**
-   * Generated bucket name based on the rules outlined in <a target="_blank" rel="noopener
-   * noreferrer"
-   * href="https://cloud.google.com/storage/docs/naming-buckets#requirements">https://cloud.google.com/storage/docs/naming-buckets#requirements</a>
-   */
-  public static Arbitrary<BucketName> bucketName() {
+  public static Arbitrary<ProjectID> projectID() {
     return Combinators.combine(
-            Arbitraries.oneOf(
-                projectID(),
-                // Global buckets have prefix of projects/_
-                Arbitraries.of(new ProjectID("_"))),
-            Arbitraries.oneOf(Arbitraries.chars().alpha(), Arbitraries.chars().numeric()),
-            Arbitraries.oneOf(
-                    Arbitraries.chars().alpha(),
-                    Arbitraries.chars().numeric(),
-                    Arbitraries.chars().with('-', '_'))
-                .list()
-                .ofMinSize(1)
-                .ofMaxSize(61),
-            Arbitraries.oneOf(Arbitraries.chars().alpha(), Arbitraries.chars().numeric()))
-        .as(
-            (p, first, mid, last) -> {
-              final StringBuilder sb = new StringBuilder();
-              sb.append(first);
-              mid.forEach(sb::append);
-              sb.append(last);
-              return BucketName.of(p.get(), sb.toString());
-            });
+            // must start with a letter
+            Arbitraries.chars().range('a', 'z'),
+            // can only contain numbers, lowercase letters, and hyphens, and must be 6-30 chars
+            Arbitraries.strings()
+                .withCharRange('a', 'z')
+                .numeric()
+                .withChars('-')
+                .ofMinLength(4)
+                .ofMaxLength(28),
+            // must not end with a hyphen
+            Arbitraries.chars().range('a', 'z').numeric())
+        .as((first, mid, last) -> new ProjectID(first + mid + last));
   }
 
   public static Buckets buckets() {
@@ -146,6 +117,30 @@ public final class StorageArbitraries {
 
     private Buckets() {}
 
+    /**
+     * Generated bucket name based on the rules outlined in <a target="_blank" rel="noopener
+     * noreferrer"
+     * href="https://cloud.google.com/storage/docs/naming-buckets#requirements">https://cloud.google.com/storage/docs/naming-buckets#requirements</a>
+     */
+    Arbitrary<BucketName> name() {
+      return Combinators.combine(
+              Arbitraries.oneOf(
+                  projectID(),
+                  // Global buckets have prefix of projects/_
+                  Arbitraries.of(new ProjectID("_"))),
+              alnum(),
+              alnum().with('-', '_').list().ofMinSize(1).ofMaxSize(61),
+              alnum())
+          .as(
+              (p, first, mid, last) -> {
+                final StringBuilder sb = new StringBuilder();
+                sb.append(first);
+                mid.forEach(sb::append);
+                sb.append(last);
+                return BucketName.of(p.get(), sb.toString());
+              });
+    }
+
     public Arbitrary<Bucket.Lifecycle.Rule.Action> actions() {
       return Combinators.combine(Arbitraries.of("Delete", "SetStorageClass"), storageClass())
           .as(
@@ -226,7 +221,7 @@ public final class StorageArbitraries {
     }
 
     public Arbitrary<Bucket.Logging> logging() {
-      Arbitrary<BucketName> loggingBucketName = StorageArbitraries.bucketName();
+      Arbitrary<BucketName> loggingBucketName = name();
       Arbitrary<String> loggingPrefix = Arbitraries.strings().all().ofMinLength(1).ofMaxLength(10);
       return Combinators.combine(loggingBucketName, loggingPrefix, bool())
           .as(
@@ -385,6 +380,24 @@ public final class StorageArbitraries {
 
     private Objects() {}
 
+    /**
+     * Generated object name based on the rules outlined in <a target="_blank" rel="noopener
+     * noreferrer"
+     * href="https://cloud.google.com/storage/docs/naming-objects#objectnames">https://cloud.google.com/storage/docs/naming-objects#objectnames</a>
+     */
+    public Arbitrary<String> name() {
+      return Arbitraries.strings()
+          .all()
+          .excludeChars('#', '[', ']', '*', '?')
+          .excludeChars(enumerate(0x7f, 0x84))
+          .excludeChars(enumerate(0x86, 0x9f))
+          .ofMinLength(1)
+          .ofMaxLength(1024)
+          .filter(s -> !s.equals("."))
+          .filter(s -> !s.equals(".."))
+          .filter(s -> !s.startsWith(".well-known/acme-challenge/"));
+    }
+
     public Arbitrary<String> storageClass() {
       // TODO: return each of the real values and edge cases (including invalid values)
       return Arbitraries.strings().all().ofMinLength(1).ofLength(1024);
@@ -420,4 +433,14 @@ public final class StorageArbitraries {
                       .build());
     }
   }
+
+  private static char[] enumerate(int lower, int upperInclusive) {
+    checkArgument(lower <= upperInclusive, "lower <= upperInclusive");
+    int length = upperInclusive - lower + 1;
+    char[] chars = new char[length];
+    for (int i = 0; i < length; i++) {
+      chars[i] = (char) (i + lower);
+    }
+    return chars;
+  }
 }